SCIM 2.0 Java client
Use this example when your own Spring Boot application owns the user accounts and must push them to Secrets Vault Identity, for example to use it as a second factor. It uses Spring WebFlux WebClient (Spring Boot 3, Java 21).
The client calls the endpoints described in General guidelines. You need the tenant's SCIM Base URL and an API key with SCIM2 scope (see Prerequisites).
Mapping your users
| Your application | SCIM attribute | Notes |
|---|---|---|
login | userName | Stored in lowercase. Send the same value as login_hint in the 2FA flow. |
| User id | externalId | Lets you find the user from either side. |
firstName, lastName | name.givenName, name.familyName | |
email | emails[0].value (primary: true, type: work) | The user receives a verification code at this address when the account is created. |
activated | active | Deactivate instead of deleting when the account is only suspended. |
(new field) vidpUserId | id | Returned on creation. It is also the sub claim of the user's tokens. |
Configuration
vidp:
scim:
base-url: https://<your-domain>/scim/v2/<tenant_id>
# The key can create and delete users: inject it from the environment or a secret store.
api-key: ${VIDP_SCIM_API_KEY}
package com.mycompany.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "vidp.scim")
public record VidpScimProperties(String baseUrl, String apiKey) {}
Register it with @EnableConfigurationProperties(VidpScimProperties.class) on a configuration class.
SCIM resources
package com.mycompany.gateway.scim;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ScimUser(
List<String> schemas,
String id,
String userName,
String externalId,
Name name,
String displayName,
List<Email> emails,
Boolean active
) {
public static final String USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Name(String givenName, String familyName) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Email(String value, String type, Boolean primary) {}
}
package com.mycompany.gateway.scim;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ScimListResponse(Integer totalResults, @JsonProperty("Resources") List<ScimUser> resources) {}
SCIM client
The client covers the operations a provisioning sync needs, and retries 429 Too Many Requests with backoff.
package com.mycompany.gateway.scim;
import com.mycompany.gateway.config.VidpScimProperties;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
@Component
public class VidpScimClient {
private static final String PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
private static final MediaType SCIM_JSON = MediaType.parseMediaType("application/scim+json");
private static final Retry RATE_LIMIT_RETRY = Retry.backoff(3, Duration.ofSeconds(2)).filter(
e -> e instanceof WebClientResponseException.TooManyRequests
);
private final WebClient webClient;
public VidpScimClient(VidpScimProperties properties) {
// Plain builder on purpose: a @LoadBalanced builder would resolve the host through service discovery.
this.webClient = WebClient.builder()
.baseUrl(properties.baseUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.apiKey())
.defaultHeader(HttpHeaders.ACCEPT, "application/scim+json, application/json")
.build();
}
public Mono<ScimUser> createUser(ScimUser user) {
return webClient.post().uri("/Users").contentType(SCIM_JSON).bodyValue(user).retrieve().bodyToMono(ScimUser.class).retryWhen(RATE_LIMIT_RETRY);
}
public Mono<ScimUser> findByUserName(String userName) {
String filter = "userName eq \"" + userName.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
return webClient
.get()
.uri(builder -> builder.path("/Users").queryParam("filter", "{filter}").build(filter))
.retrieve()
.bodyToMono(ScimListResponse.class)
.retryWhen(RATE_LIMIT_RETRY)
.flatMap(list -> list.resources() == null || list.resources().isEmpty() ? Mono.empty() : Mono.just(list.resources().get(0)));
}
/** Full update: attributes you leave out are cleared. */
public Mono<ScimUser> replaceUser(String id, ScimUser user) {
return webClient
.put()
.uri("/Users/{id}", id)
.contentType(SCIM_JSON)
.bodyValue(user)
.retrieve()
.bodyToMono(ScimUser.class)
.retryWhen(RATE_LIMIT_RETRY);
}
public Mono<Void> setActive(String id, boolean active) {
Map<String, Object> patch = Map.of(
"schemas",
List.of(PATCH_SCHEMA),
"Operations",
List.of(Map.of("op", "replace", "path", "active", "value", active))
);
return webClient
.patch()
.uri("/Users/{id}", id)
.contentType(SCIM_JSON)
.bodyValue(patch)
.retrieve()
.toBodilessEntity()
.retryWhen(RATE_LIMIT_RETRY)
.then();
}
public Mono<Void> deleteUser(String id) {
return webClient
.delete()
.uri("/Users/{id}", id)
.retrieve()
.toBodilessEntity()
.retryWhen(RATE_LIMIT_RETRY)
// Already gone is fine for a delete.
.onErrorResume(WebClientResponseException.NotFound.class, e -> Mono.empty())
.then();
}
}
Provisioning service
This service maps your User to SCIM and stores the returned id on the local user. If the user already exists (409 Conflict), it finds it by userName and updates it instead.
package com.mycompany.gateway.scim;
import com.mycompany.gateway.domain.User;
import com.mycompany.gateway.repository.UserRepository;
import java.time.Duration;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
@Service
public class VidpUserProvisioning {
private static final Logger log = LoggerFactory.getLogger(VidpUserProvisioning.class);
private final VidpScimClient scimClient;
private final UserRepository userRepository;
public VidpUserProvisioning(VidpScimClient scimClient, UserRepository userRepository) {
this.scimClient = scimClient;
this.userRepository = userRepository;
}
/** Creates or updates the user in Secrets Vault Identity and stores the returned id. */
public Mono<User> provision(User user) {
ScimUser scimUser = toScim(user);
Mono<ScimUser> upsert = user.getVidpUserId() != null
? scimClient.replaceUser(user.getVidpUserId(), scimUser)
: scimClient
.createUser(scimUser)
.onErrorResume(WebClientResponseException.Conflict.class, e ->
scimClient.findByUserName(user.getLogin()).flatMap(existing -> scimClient.replaceUser(existing.id(), scimUser))
);
return upsert.flatMap(saved -> {
user.setVidpUserId(saved.id());
return userRepository.save(user);
});
}
public Mono<Void> deactivate(User user) {
return user.getVidpUserId() == null ? Mono.empty() : scimClient.setActive(user.getVidpUserId(), false);
}
public Mono<Void> delete(User user) {
return user.getVidpUserId() == null ? Mono.empty() : scimClient.deleteUser(user.getVidpUserId());
}
/** One-off initial load of existing users, paced to stay under 200 requests per minute. */
public Mono<Long> backfill() {
return userRepository
.findAll()
.filter(User::isActivated)
.delayElements(Duration.ofMillis(350))
.concatMap(user ->
provision(user).onErrorResume(e -> {
log.warn("SCIM provisioning failed for {}: {}", user.getLogin(), e.getMessage());
return Mono.empty();
})
)
.count();
}
private static ScimUser toScim(User user) {
String displayName = String.join(" ", List.of(nullToEmpty(user.getFirstName()), nullToEmpty(user.getLastName()))).trim();
return new ScimUser(
List.of(ScimUser.USER_SCHEMA),
null,
user.getLogin(),
user.getId(),
new ScimUser.Name(user.getFirstName(), user.getLastName()),
displayName.isEmpty() ? user.getLogin() : displayName,
user.getEmail() == null ? null : List.of(new ScimUser.Email(user.getEmail(), "work", true)),
user.isActivated()
);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
Hooking it into user management
Call the provisioning service wherever your application changes users. In a reactive JHipster UserService, for example:
public Mono<User> createUser(AdminUserDTO userDTO) {
return /* existing creation chain */
.flatMap(user ->
vidpUserProvisioning
.provision(user)
// Do not block account creation if Secrets Vault Identity is unreachable:
// log it and let a scheduled backfill retry.
.onErrorResume(e -> {
log.warn("SCIM provisioning failed for {}", user.getLogin(), e);
return Mono.just(user);
})
);
}
public Mono<Void> deleteUser(String login) {
return userRepository
.findOneByLogin(login)
.flatMap(user -> vidpUserProvisioning.delete(user).then(userRepository.delete(user)));
}
Do the same in updateUser (call provision) and wherever a user is deactivated (call deactivate).
SCIM calls happen outside your database transaction. In production, record failed calls (or use an outbox table) and replay them, and run backfill() on a schedule as a reconciliation job. Bulk operations are not supported, so the backfill sends one request per user.