Using Spring Web Services to integrate with the Dutch CCBR SOAP services
Last week I got assigned with the task to integrate with a Dutch judiciary service that allows one to check if someone has been placed under guardianship, the “Centraal Curatele- en bewindsregister” or CCBR for short.
As I read their documentation I wasn’t very happy: there are two separate SOAP 1.2 services that you have to integrate with. The first one uses WS-Security with a username and password and returns a SAML assertion in its response.
The second one requires that exact assertion as an element in a WS-Security SOAP header, and also requires a custom truststore for the TLS handshake.
The WSDLs were referring to WS-Trust policies and other things that I’d rather stay away from, and that Spring Web Services has no direct support for.
The framework does, however, provide support for WS-Security and WS-Addressing that I required. With that, I tried to build a client that integrates with the services using Spring-WS’s WebServiceTemplate and got it to work after some hours of tweaking and reading source code.
This blog describes the result of that exercise.
Fetching the SAML Assertion token
The first service you have to integrate with is the Secure Token Service, or STS for short. This one requires a username and password to be passed in a WS-Security header, and has a fixed body as its payload.
Spring-WS provides WS-Security support by integrating with Apache WSS4J via an interceptor. This interceptor can be used both client- and server-side. The first thing you need to do is to configure it to send the necessary WS-Security headers:
var securityInterceptor = new Wss4jSecurityInterceptor();
securityInterceptor.setSecurementActions(WSHandlerConstants.TIMESTAMP +
" " + WSHandlerConstants.USERNAME_TOKEN);
securityInterceptor.setSecurementPasswordType(WSConstants.PW_TEXT);
securityInterceptor.setSecurementUsername(username);
securityInterceptor.setSecurementPassword(password);
The actions property is a space-separated list in the form of a String; this really shows that was written in a time where XML-based configuration was still the norm 🙂
This interceptor can now be registered with a WebServiceTemplate. As I’m using this in a Spring Boot project, I chose to use their WebServiceTemplateBuilder for this.
Besides the interceptor there are two other things you need to configure: we want to use SOAP 1.2 (1.1 is the default) and we want to register a JAXB-based (un)marshaller.
The resulting WebserviceTemplate will be used by a component that fetches and caches the SAML assertion token and allows it to be added to requests to the data services. For that, I created an StsTokenManager type.
As I’m setting this all up in a Boot AutoConfiguration class in a library, this is what we end up with (CcbrProperties is a Boot @ConfigurationProperties type):
@AutoConfiguration
@EnableConfigurationProperties(CcbrProperties.class)
public class CcbrAutoConfiguration {
@Autowired CcbrProperties ccbr;
WebServiceTemplateCustomizer ccbrCustomizer;
public CcbrAutoConfiguration() throws Exception {
var msgFactory = new SaajSoapMessageFactory();
msgFactory.setSoapVersion(SoapVersion.SOAP_12);
msgFactory.afterPropertiesSet();
Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
marshaller.setPackagesToScan("nl.trifork.ccbr.model");
marshaller.afterPropertiesSet();
ccbrCustomizer = template -> {
template.setMessageFactory(msgFactory);
template.setMarshaller(marshaller);
template.setUnmarshaller(marshaller);
};
}
@Bean
StsTokenManager stsTokenManager(WebServiceTemplateBuilder builder) {
var securityInterceptor = new Wss4jSecurityInterceptor();
securityInterceptor.setSecurementActions(
WSHandlerConstants.TIMESTAMP + " " +
WSHandlerConstants.USERNAME_TOKEN);
securityInterceptor.setSecurementPasswordType(WSConstants.PW_TEXT);
securityInterceptor.setSecurementUsername(ccbr.getStsUsername());
securityInterceptor.setSecurementPassword(ccbr.getStsPassword());
var stsTemplate = builder.additionalCustomizers(ccbrCustomizer)
.additionalInterceptors(securityInterceptor)
.setDefaultUri(ccbr.getStsUrl())
.build();
return new StsTokenManager(stsTemplate);
}
…
}
Now we can implement the component that calls the STS operation using the configured template. It requires WS-Addressing headers and has a fixed XML payload for the body. The response contains a SAML assertion within a WS-Trust RequestedAssertionToken element that we need to extract.
This is how I implemented that (this is not the full version of the class yet):
public class StsTokenManager {
private static final ActionCallback ADDRESSING_ACTION = new ActionCallback(
URI.create("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue"),
new Addressing10(),
URI.create("https://sts.rechtspraak.nl/adfs/services/trust/13/usernamemixed"));
static {
ADDRESSING_ACTION.setReplyTo(new EndpointReference(URI.create("http://www.w3.org/2005/08/addressing/anonymous")));
}
private static final StringSource BODY = new StringSource("""
<trust:RequestSecurityToken xmlns:trust="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
<wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
<wsa:Address>https://curateleenbewindregisterservice.rechtspraak.nl/</wsa:Address>
</wsa:EndpointReference>
</wsp:AppliesTo>
<trust:KeyType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer</trust:KeyType>
<trust:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</trust:RequestType>
</trust:RequestSecurityToken>
""");
private static final XPathExpression ASSERTION_PATH = XPathExpressionFactory.createXPathExpression(
"//trust:RequestedSecurityToken/saml:Assertion",
Map.of("trust", "http://docs.oasis-open.org/ws-sx/ws-trust/200512",
"saml", "urn:oasis:names:tc:SAML:1.0:assertion")
);
final WebServiceTemplate wsTemplate;
private final SourceExtractor<Element> assertionExtractor;
private volatile Element token;
public StsTokenManager(WebServiceTemplate stsTemplate) {
this.wsTemplate = stsTemplate;
this.assertionExtractor = source -> (Element) ASSERTION_PATH.evaluateAsNode(((DOMSource) source).getNode());
}
…
void refreshToken() {
this.token = wsTemplate.sendSourceAndReceive(BODY, ADDRESSING_ACTION, assertionExtractor);
}
}
The template isn’t private, so that we can access it from an integration test for use with Spring-WS’s mock server support.
OK, that’s a good start: let’s move on to the second service.
Calling the data services
This service is using an HTTPS connection, but with a TLS certificate that’s not signed by CAs present in Java’s default truststore. That means you’ll have to import two certificates in a truststore and then make the WebserviceTemplate use an HTTP client that in turn uses that truststore.
We’re using the Apache Components HTTP client. Since Spring 6, that requires version 4.5.x of the client.
In our autoconfiguration I added a helper method that looks like this (real version has some additional config like timeouts):
private HttpComponentsClientHttpRequestFactory createRequestFactory() throws Exception {
// ensure we use a custom truststore for the TLS handshake
var sslContext = SSLContextBuilder.create()
.loadTrustMaterial(new ClassPathResource("ccbr-truststore.pkcs12").getURL())
.build();
HttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setConnectionTimeToLive(TimeValue.ofSeconds(60L))
.setMaxConnPerRoute(100)
.setMaxConnTotal(100)
.setSSLSocketFactory(
SSLConnectionSocketFactoryBuilder.create()
.setSslContext(sslContext)
.build())
.build();
return new HttpComponentsClientHttpRequestFactory(
HttpClients.custom()
.setConnectionManager(connectionManager)
.build());
}
Now this can be used to create a ClientHttpRequestMessageSender for use with a WebServiceTemplate.
To tell the interceptor that we want to add a custom XML Element to the security header, there’s support for a “CUSTOM_TOKEN” action. The supporting code expects to be able to find a password callback object in the WSS4J context that will return the element to add.
We can make our StsTokenManager extend the required abstract parent class and implement the corresponding method. It should also cache the token, which expires after an hour.
I usually choose to not actively refresh tokens before they expire, as I will need to implement support to refresh tokens when there’s an error indicating that they expired anyway
This is what we end up with, then (not showing code that was shown already):
public class StsTokenManager extends AbstractWsPasswordCallbackHandler {
…
private volatile Element token;
private long lastRefresh;
@Override
protected void handleCustomToken(WSPasswordCallback callback) {
callback.setCustomToken(currentToken());
}
Element currentToken() {
if (token == null) {
refreshToken();
}
return token;
}
synchronized void refreshToken() {
if (alreadyRefreshed()) {
logger.debug("Token already refreshed");
return;
}
this.token = wsTemplate.sendSourceAndReceive(BODY, ADDRESSING_ACTION, assertionExtractor);
this.lastRefresh = System.currentTimeMillis();
}
private boolean alreadyRefreshed() {
return token != null && (System.currentTimeMillis() - lastRefresh < 10_000L);
}
}
We’re making good progress here, but how do we make this manager available as the callback handler to WSS4J? It turns out that the interceptor doesn’t have a direct way of registering it for clients: there’s only a method to register this for server-side code.
However, after looking at how Spring-WS integrates with WSS4J it becomes clear that this isn’t very hard to do using another interceptor.
Now we can create another WebServiceTemplate that we inject into a custom client.
While testing I also noticed that errors that occur now when adding the SAML token are not causing exceptions by default: you can fix that by overriding a method from the security interceptor.
The result then looks as follows (this is from our auto-configuration class):
@Bean
CcbrClient ccbrClient(WebServiceTemplateBuilder builder, StsTokenManager tokenManager) throws Exception {
var callbackRegisteringInterceptor = new ClientInterceptorAdapter() {
/** Ensure our manager is used as a callback when retrieving the custom element for the Security header. */
@Override
public boolean handleRequest(MessageContext messageContext) throws WebServiceClientException {
messageContext.setProperty(WSHandlerConstants.PW_CALLBACK_REF, tokenManager);
return true;
}
};
var securityInterceptor = new Wss4jSecurityInterceptor() {
/** Default impl logs confusing (server) error and returns false: rethrow instead. */
@Override
protected boolean handleSecurementException(WsSecuritySecurementException ex, MessageContext messageContext) {
Throwable cause = NestedExceptionUtils.getMostSpecificCause(ex);
throw new CcbrException("CCBR STS error \"" + cause.getMessage() + "\"", ex);
}
};
// use a custom token action as we insert the received
// SAML Assertion element as-is under the Security header
securityInterceptor.setSecurementActions(
WSHandlerConstants.TIMESTAMP + " " + WSHandlerConstants.CUSTOM_TOKEN);
var dataTemplate = builder.additionalCustomizers(ccbrCustomizer)
.additionalInterceptors(callbackRegisteringInterceptor, securityInterceptor)
.setDefaultUri(ccbr.getDataUrl())
.messageSenders(new ClientHttpRequestMessageSender(createRequestFactory()))
.build();
return new CcbrClient(dataTemplate, tokenManager);
}
The implementation of that CcbrClient is now straightforward, as all the hard work has been done already:
public class CcbrClient {
private static final ActionCallback RAADPLEEG_ADDRESSING_ACTION = actionFor("ccbr.rechtspraak.nl/v1/CcbrDataservice/RaadpleegRegisterkaart");
private static final ActionCallback ZOEK_ADDRESSING_ACTION = actionFor("ccbr.rechtspraak.nl/v1/CcbrDataservice/ZoekRegisterkaarten");
static ActionCallback actionFor(String uri) {
var callback = new ActionCallback(
URI.create(uri), new Addressing10(), URI.create("https://curateleenbewindregisterservice.rechtspraak.nl/ccbrdataservice.svc"));
callback.setReplyTo(new EndpointReference(URI.create("http://www.w3.org/2005/08/addressing/anonymous")));
return callback;
}
final WebServiceTemplate wsTemplate;
private final StsTokenManager tokenManager;
public CcbrClient(WebServiceTemplate dataTemplate, StsTokenManager tokenManager) {
this.wsTemplate = dataTemplate;
this.tokenManager = tokenManager;
}
public RaadpleegRegisterkaartResponse consult(RaadpleegRegisterkaart request) throws CcbrException {
return sendAndReceive(request, RAADPLEEG_ADDRESSING_ACTION);
}
public ZoekRegisterkaartenResponse search(ZoekRegisterkaarten request) throws CcbrException {
return sendAndReceive(request, ZOEK_ADDRESSING_ACTION);
}
private <R> R sendAndReceive(Object request, ActionCallback actionCallback) {
return sendAndReceive(request, actionCallback, true);
}
private <R> R sendAndReceive(Object request, ActionCallback actionCallback, boolean retryOnAuthFailure) {
try {
return (R) wsTemplate.marshalSendAndReceive(request, actionCallback);
} catch (SoapFaultClientException e) {
if (retryOnAuthFailure && e.getFaultStringOrReason().contains("security token")) {
tokenManager.refreshToken();
return sendAndReceive(request, actionCallback, false);
}
throw new CcbrException("CCBR Data SOAP Fault \"" + e.getMessage() + "\"", e);
} catch (WebServiceException e) {
Throwable cause = NestedExceptionUtils.getMostSpecificCause(e);
throw new CcbrException("Error communicating with CCBR Data Service: " + cause.getMessage(), e);
}
}
}
Note that this class is responsible for recognizing when a token has expired: if that happens it tells the StsManager to refresh it and then retries the request.
The types that you see like ZoekRegisterkaartenResponse are simply the result of using xjc to generate Java types annotated with JAXB2 annotation from the XSDs that belong to the WSDL.
Conclusion
The last time I’ve used Spring-WS with WS-Security was probably over a decade ago. It’s nice to see that even though the framework is basically finished, it’s still actively maintained to remain compatible with new Spring and Spring Boot versions, and as it turns out is even able to make you comply with weird requirements like copying entire XML fragments into WS-Security headers without having to implement all WS-Security code yourself.
When googling for ways to integrate with the CCBR service I had hoped to stumble upon a blog post like this, so in case you ended up here by googling for the same thing: enjoy, I hope this helped!