From 2f0e9a623f1c03a08a2b2a7dbe86345789c7293d Mon Sep 17 00:00:00 2001 From: Reto Wettstein Date: Thu, 18 Jul 2024 08:25:45 +0200 Subject: [PATCH] client credentials workflow implementation --- .../common/fhir/client/FhirClientFactory.java | 14 +- .../common/fhir/client/FhirClientImpl.java | 22 +- .../client/interceptor/OAuth2Interceptor.java | 40 ++++ .../common/fhir/client/token/AccessToken.java | 34 +++ .../fhir/client/token/OAuth2TokenClient.java | 200 ++++++++++++++++++ .../client/token/OAuth2TokenProvider.java | 40 ++++ .../common/fhir/client/token/TokenClient.java | 8 + .../fhir/client/token/TokenProvider.java | 8 + 8 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/interceptor/OAuth2Interceptor.java create mode 100644 src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/AccessToken.java create mode 100644 src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenClient.java create mode 100644 src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenProvider.java create mode 100644 src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenClient.java create mode 100644 src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenProvider.java diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientFactory.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientFactory.java index ef3ec80..a8b390e 100644 --- a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientFactory.java +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientFactory.java @@ -17,6 +17,7 @@ import ca.uhn.fhir.context.FhirContext; import de.medizininformatik_initiative.processes.common.fhir.client.logging.DataLogger; +import de.medizininformatik_initiative.processes.common.fhir.client.token.TokenProvider; import de.rwh.utils.crypto.CertificateHelper; import de.rwh.utils.crypto.io.CertificateReader; import de.rwh.utils.crypto.io.PemIo; @@ -38,6 +39,7 @@ public class FhirClientFactory private final String fhirServerBasicAuthUsername; private final String fhirServerBasicAuthPassword; private final String fhirServerBearerToken; + private final TokenProvider fhirServerOAuth2TokenProvider; private final String proxyUrl; private final String proxyUsername; @@ -54,8 +56,8 @@ public class FhirClientFactory public FhirClientFactory(Path trustStorePath, Path certificatePath, Path privateKeyPath, char[] privateKeyPassword, int connectTimeout, int socketTimeout, int connectionRequestTimeout, String fhirServerBase, String fhirServerBasicAuthUsername, String fhirServerBasicAuthPassword, String fhirServerBearerToken, - String proxyUrl, String proxyUsername, String proxyPassword, boolean hapiClientVerbose, - FhirContext fhirContext, String localIdentifierValue, DataLogger dataLogger) + TokenProvider fhirServerOAuth2TokenProvider, String proxyUrl, String proxyUsername, String proxyPassword, + boolean hapiClientVerbose, FhirContext fhirContext, String localIdentifierValue, DataLogger dataLogger) { this.trustStorePath = trustStorePath; this.certificatePath = certificatePath; @@ -70,6 +72,7 @@ public FhirClientFactory(Path trustStorePath, Path certificatePath, Path private this.fhirServerBasicAuthUsername = fhirServerBasicAuthUsername; this.fhirServerBasicAuthPassword = fhirServerBasicAuthPassword; this.fhirServerBearerToken = fhirServerBearerToken; + this.fhirServerOAuth2TokenProvider = fhirServerOAuth2TokenProvider; this.proxyUrl = proxyUrl; this.proxyUsername = proxyUsername; @@ -85,11 +88,12 @@ public FhirClientFactory(Path trustStorePath, Path certificatePath, Path private public void testConnection() { + // TODO: log token provider configuration try { logger.info( "Testing connection to FHIR server with {trustStorePath: {}, certificatePath: {}, privateKeyPath: {}, privateKeyPassword: {}," - + " basicAuthUsername {}, basicAuthPassword {}, bearerToken {}, serverBase: {}, proxyUrl {}, proxyUsername {}, proxyPassword {}}", + + " basicAuthUsername: {}, basicAuthPassword: {}, bearerToken: {}, serverBase: {}, proxyUrl: {}, proxyUsername: {}, proxyPassword: {}}", trustStorePath, certificatePath, privateKeyPath, privateKeyPassword != null ? "***" : "null", fhirServerBasicAuthUsername, fhirServerBasicAuthPassword != null ? "***" : "null", fhirServerBearerToken != null ? "***" : "null", fhirServerBase, proxyUrl, proxyUsername, @@ -137,8 +141,8 @@ protected FhirClient createFhirClientImpl() return new FhirClientImpl(trustStore, keyStore, keyStorePassword, connectTimeout, socketTimeout, connectionRequestTimeout, fhirServerBasicAuthUsername, fhirServerBasicAuthPassword, - fhirServerBearerToken, fhirServerBase, proxyUrl, proxyUsername, proxyPassword, hapiClientVerbose, - fhirContext, localIdentifierValue, dataLogger); + fhirServerBearerToken, fhirServerOAuth2TokenProvider, fhirServerBase, proxyUrl, proxyUsername, + proxyPassword, hapiClientVerbose, fhirContext, localIdentifierValue, dataLogger); } private KeyStore readTrustStore(Path trustPath) diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientImpl.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientImpl.java index 90bb0ab..ddabaaa 100644 --- a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientImpl.java +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/FhirClientImpl.java @@ -25,8 +25,10 @@ import ca.uhn.fhir.rest.client.interceptor.BearerTokenAuthInterceptor; import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor; import ca.uhn.fhir.rest.gclient.IReadTyped; +import de.medizininformatik_initiative.processes.common.fhir.client.interceptor.OAuth2Interceptor; import de.medizininformatik_initiative.processes.common.fhir.client.logging.DataLogger; import de.medizininformatik_initiative.processes.common.fhir.client.logging.HapiClientLogger; +import de.medizininformatik_initiative.processes.common.fhir.client.token.TokenProvider; public class FhirClientImpl implements FhirClient { @@ -39,6 +41,7 @@ public class FhirClientImpl implements FhirClient private final String fhirServerBasicAuthUsername; private final String fhirServerBasicAuthPassword; private final String fhirServerBearerToken; + private final TokenProvider fhirServerOAuth2TokenProvider; private final boolean hapiClientVerbose; @@ -50,9 +53,10 @@ public class FhirClientImpl implements FhirClient public FhirClientImpl(KeyStore trustStore, KeyStore keyStore, char[] keyStorePassword, int connectTimeout, int socketTimeout, int connectionRequestTimeout, String fhirServerBasicAuthUsername, - String fhirServerBasicAuthPassword, String fhirServerBearerToken, String fhirServerBase, String proxyUrl, - String proxyUsername, String proxyPassword, boolean hapiClientVerbose, FhirContext fhirContext, - String localIdentifierValue, DataLogger dataLogger) + String fhirServerBasicAuthPassword, String fhirServerBearerToken, + TokenProvider fhirServerOAuth2TokenProvider, String fhirServerBase, String proxyUrl, String proxyUsername, + String proxyPassword, boolean hapiClientVerbose, FhirContext fhirContext, String localIdentifierValue, + DataLogger dataLogger) { clientFactory = createClientFactory(trustStore, keyStore, keyStorePassword, connectTimeout, socketTimeout, connectionRequestTimeout); @@ -62,6 +66,7 @@ public FhirClientImpl(KeyStore trustStore, KeyStore keyStore, char[] keyStorePas this.fhirServerBasicAuthUsername = fhirServerBasicAuthUsername; this.fhirServerBasicAuthPassword = fhirServerBasicAuthPassword; this.fhirServerBearerToken = fhirServerBearerToken; + this.fhirServerOAuth2TokenProvider = fhirServerOAuth2TokenProvider; configureProxy(clientFactory, proxyUrl, proxyUsername, proxyPassword); @@ -118,12 +123,18 @@ private void configuredWithBasicAuth(IGenericClient client) new BasicAuthInterceptor(fhirServerBasicAuthUsername, fhirServerBasicAuthPassword)); } - private void configureBearerTokenAuthInterceptor(IGenericClient client) + private void configuredWithBearerTokenAuth(IGenericClient client) { if (fhirServerBearerToken != null) client.registerInterceptor(new BearerTokenAuthInterceptor(fhirServerBearerToken)); } + private void configuredWithOAuth(IGenericClient client) + { + if (fhirServerOAuth2TokenProvider != null && fhirServerOAuth2TokenProvider.isConfigured()) + client.registerInterceptor(new OAuth2Interceptor(fhirServerOAuth2TokenProvider)); + } + private void configureLoggingInterceptor(IGenericClient client) { if (hapiClientVerbose) @@ -158,7 +169,8 @@ public IGenericClient getGenericFhirClient() IGenericClient client = clientFactory.newGenericClient(fhirServerBase); configuredWithBasicAuth(client); - configureBearerTokenAuthInterceptor(client); + configuredWithBearerTokenAuth(client); + configuredWithOAuth(client); configureLoggingInterceptor(client); return client; diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/interceptor/OAuth2Interceptor.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/interceptor/OAuth2Interceptor.java new file mode 100644 index 0000000..c198829 --- /dev/null +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/interceptor/OAuth2Interceptor.java @@ -0,0 +1,40 @@ +package de.medizininformatik_initiative.processes.common.fhir.client.interceptor; + +import java.util.Objects; + +import org.springframework.beans.factory.InitializingBean; + +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.client.api.IClientInterceptor; +import ca.uhn.fhir.rest.client.api.IHttpRequest; +import ca.uhn.fhir.rest.client.api.IHttpResponse; +import de.medizininformatik_initiative.processes.common.fhir.client.token.TokenProvider; + +public class OAuth2Interceptor implements IClientInterceptor, InitializingBean +{ + private final TokenProvider tokenProvider; + + public OAuth2Interceptor(TokenProvider tokenProvider) + { + this.tokenProvider = tokenProvider; + } + + @Override + public void afterPropertiesSet() + { + Objects.requireNonNull(tokenProvider, "tokenProvider"); + } + + @Override + public void interceptRequest(IHttpRequest theRequest) + { + theRequest.addHeader(Constants.HEADER_AUTHORIZATION, + Constants.HEADER_AUTHORIZATION_VALPREFIX_BEARER + tokenProvider.getToken()); + } + + @Override + public void interceptResponse(IHttpResponse theResponse) + { + // do not intercept response + } +} diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/AccessToken.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/AccessToken.java new file mode 100644 index 0000000..0523ece --- /dev/null +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/AccessToken.java @@ -0,0 +1,34 @@ +package de.medizininformatik_initiative.processes.common.fhir.client.token; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class AccessToken +{ + private static final int BUFFER = 10; + + private final String token; + + private final LocalDateTime expiresAt; + + @JsonCreator + public AccessToken(@JsonProperty("access_token") String token, @JsonProperty("expires_in") int expiresIn) + { + this.token = token; + this.expiresAt = LocalDateTime.now().plusSeconds(expiresIn); + } + + public String get() + { + return token; + } + + public boolean isExpired() + { + return LocalDateTime.now().plusSeconds(BUFFER).isAfter(expiresAt); + } +} diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenClient.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenClient.java new file mode 100644 index 0000000..ccd0a8d --- /dev/null +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenClient.java @@ -0,0 +1,200 @@ +package de.medizininformatik_initiative.processes.common.fhir.client.token; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.rwh.utils.crypto.io.CertificateReader; + +public class OAuth2TokenClient implements TokenClient, InitializingBean +{ + private static final Logger logger = LoggerFactory.getLogger(OAuth2TokenClient.class); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final String issuerUrl; + private final String clientId; + private final String clientSecret; + + private final int connectTimeout; + private final int socketTimeout; + + private final Path trustStorePath; + + private final String proxyUrl; + private final String proxyUsername; + private final String proxyPassword; + + public OAuth2TokenClient(String issuerUrl, String clientId, String clientSecret, int connectTimeout, + int socketTimeout, Path trustStorePath, String proxyUrl, String proxyUsername, String proxyPassword) + { + this.issuerUrl = issuerUrl; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.connectTimeout = connectTimeout; + this.socketTimeout = socketTimeout; + this.trustStorePath = trustStorePath; + this.proxyUrl = proxyUrl; + this.proxyUsername = proxyUsername; + this.proxyPassword = proxyPassword; + } + + @Override + public void afterPropertiesSet() + { + Objects.requireNonNull(issuerUrl, "issuerUrl"); + Objects.requireNonNull(clientId, "clientId"); + Objects.requireNonNull(clientSecret, "clientSecret"); + + if (connectTimeout < 0) + throw new IllegalArgumentException("connectTimeout < 0"); + + if (socketTimeout < 0) + throw new IllegalArgumentException("socketTimeout < 0"); + } + + @Override + public boolean isConfigured() + { + return issuerUrl != null && clientId != null && clientSecret != null; + } + + @Override + public AccessToken requestToken() + { + try + { + HttpClient client = createClient(); + HttpRequest request = createAccessTokenRequest(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + return OBJECT_MAPPER.readValue(response.body(), AccessToken.class); + } + catch (IOException | InterruptedException exception) + { + throw new RuntimeException(exception); + } + } + + private HttpClient createClient() + { + HttpClient.Builder builder = HttpClient.newBuilder(); + builder.connectTimeout(Duration.ofMillis(connectTimeout)); + + configureProxy(builder); + configureTruststore(builder); + + return builder.build(); + } + + private void configureTruststore(HttpClient.Builder builder) + { + if (trustStorePath != null) + { + logger.debug("Reading trust-store from {}", trustStorePath.toString()); + KeyStore trustStore = readTrustStore(trustStorePath); + SSLContext sslContext = createSslContext(trustStore); + builder.sslContext(sslContext); + } + } + + private SSLContext createSslContext(KeyStore trustStore) + { + try + { + TrustManagerFactory trustManagerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + + SSLContext sslContext = SSLContext.getDefault(); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + + return sslContext; + } + catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException exception) + { + throw new RuntimeException(exception); + } + } + + private KeyStore readTrustStore(Path trustPath) + { + try + { + return CertificateReader.allFromCer(trustPath); + } + catch (NoSuchAlgorithmException | CertificateException | KeyStoreException | IOException exception) + { + throw new RuntimeException(exception); + } + } + + private void configureProxy(HttpClient.Builder builder) + { + if (proxyUrl != null) + { + URI uri = URI.create(proxyUrl); + builder.proxy(ProxySelector.of(new InetSocketAddress(uri.getHost(), uri.getPort()))); + } + } + + private HttpRequest createAccessTokenRequest() + { + HttpRequest.Builder builder = HttpRequest.newBuilder(); + builder.uri(URI.create(issuerUrl)); + builder.timeout(Duration.ofMillis(socketTimeout)); + + configureAuthentication(builder); + configureProxyAuthentication(builder); + + builder.header("Content-Type", "application/x-www-form-urlencoded"); + builder.POST(HttpRequest.BodyPublishers.ofString("grant_type=client_credentials")); + + return builder.build(); + } + + private void configureAuthentication(HttpRequest.Builder builder) + { + // Keycloak not sending WWW-Authenticate header for response code 401 + String credentials = getCredentials(clientId, clientSecret); + builder.header("Authorization", "Basic " + credentials); + } + + private void configureProxyAuthentication(HttpRequest.Builder builder) + { + // Proxy authentication using similar workaround as basic authentication + if (proxyUrl != null && proxyUsername != null && proxyPassword != null) + { + String proxyCredentials = getCredentials(proxyUsername, proxyPassword); + builder.setHeader("Proxy-Authorization", "Basic " + proxyCredentials); + } + } + + private String getCredentials(String username, String password) + { + String credentials = username + ":" + password; + return Base64.getEncoder().encodeToString(credentials.getBytes()); + } +} diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenProvider.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenProvider.java new file mode 100644 index 0000000..4f6a767 --- /dev/null +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/OAuth2TokenProvider.java @@ -0,0 +1,40 @@ +package de.medizininformatik_initiative.processes.common.fhir.client.token; + +import java.util.Objects; + +import org.springframework.beans.factory.InitializingBean; + +public class OAuth2TokenProvider implements TokenProvider, InitializingBean +{ + private final TokenClient tokenClient; + + private AccessToken token; + + public OAuth2TokenProvider(TokenClient tokenClient) + { + this.tokenClient = tokenClient; + } + + @Override + public void afterPropertiesSet() + { + Objects.requireNonNull(tokenClient, "tokenClient"); + } + + @Override + public boolean isConfigured() + { + return tokenClient.isConfigured(); + } + + @Override + public String getToken() + { + if (token == null || token.isExpired()) + { + token = tokenClient.requestToken(); + } + + return token.get(); + } +} diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenClient.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenClient.java new file mode 100644 index 0000000..7b7ab0e --- /dev/null +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenClient.java @@ -0,0 +1,8 @@ +package de.medizininformatik_initiative.processes.common.fhir.client.token; + +public interface TokenClient +{ + boolean isConfigured(); + + AccessToken requestToken(); +} diff --git a/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenProvider.java b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenProvider.java new file mode 100644 index 0000000..c936920 --- /dev/null +++ b/src/main/java/de/medizininformatik_initiative/processes/common/fhir/client/token/TokenProvider.java @@ -0,0 +1,8 @@ +package de.medizininformatik_initiative.processes.common.fhir.client.token; + +public interface TokenProvider +{ + boolean isConfigured(); + + String getToken(); +}