diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f224144f..64146d7e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -25,6 +25,7 @@ jobs:
- none
- basic
- cert
+ - oauth2
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
diff --git a/auth/README.md b/auth/README.md
new file mode 100644
index 00000000..79c5ceeb
--- /dev/null
+++ b/auth/README.md
@@ -0,0 +1 @@
+# Authn/Authz Util
diff --git a/auth/compose.yaml b/auth/compose.yaml
new file mode 100644
index 00000000..a2fff04b
--- /dev/null
+++ b/auth/compose.yaml
@@ -0,0 +1,23 @@
+name: fts-auth
+
+services:
+ keycloak:
+ image: quay.io/keycloak/keycloak:25.0.6
+ command: [ "start-dev" ]
+ environment:
+ KEYCLOAK_ADMIN: admin
+ KEYCLOAK_ADMIN_PASSWORD: admin
+ ports:
+ - "8080:8080"
+ networks: [ "clinical-domain", "research-domain", "trust-center" ]
+
+networks:
+ clinical-domain:
+ external: true
+ name: fts-test_clinical-domain
+ research-domain:
+ external: true
+ name: fts-test_research-domain
+ trust-center:
+ external: true
+ name: fts-test_trust-center
diff --git a/clinical-domain-agent/application-auth:oauth2.yaml b/clinical-domain-agent/application-auth:oauth2.yaml
new file mode 100644
index 00000000..87ebc33e
--- /dev/null
+++ b/clinical-domain-agent/application-auth:oauth2.yaml
@@ -0,0 +1,31 @@
+server:
+ port: 9090
+
+projects:
+ directory: "../.github/test/cd-agent/projects"
+
+security:
+ auth:
+ oauth2:
+ issuer: http://localhost:8080/realms/master
+
+spring:
+ security:
+ oauth2:
+ client:
+ registration:
+ agent:
+ authorizationGrantType: client_credentials
+ clientId: fts/cd-agent
+ clientSecret: eA4xj1zFxsVYZGdLah9KnkcmHYDBjojr
+ provider: keycloak
+ provider:
+ keycloak:
+ issuer-uri: http://localhost:8080/realms/master
+
+test:
+ webclient:
+ default:
+ auth:
+ oauth2:
+ registration: agent
diff --git a/util/pom.xml b/util/pom.xml
index 8670ed4f..6a9fc3bc 100644
--- a/util/pom.xml
+++ b/util/pom.xml
@@ -81,6 +81,21 @@
spring-boot-starter-security
+
+ org.springframework.security
+ spring-security-oauth2-client
+
+
+
+ org.springframework.security
+ spring-security-oauth2-resource-server
+
+
+
+ org.springframework.security
+ spring-security-oauth2-jose
+
+
org.mock-server
mockserver-junit-jupiter
diff --git a/util/src/main/java/care/smith/fts/util/AgentConfiguration.java b/util/src/main/java/care/smith/fts/util/AgentConfiguration.java
index 93a48f39..61080185 100644
--- a/util/src/main/java/care/smith/fts/util/AgentConfiguration.java
+++ b/util/src/main/java/care/smith/fts/util/AgentConfiguration.java
@@ -7,10 +7,16 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.net.http.HttpClient;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
+import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager;
+import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
+import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder;
+import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService;
+import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
@Configuration
@Import({
@@ -38,4 +44,20 @@ public HttpClient httpClient() {
public ObjectMapper defaultObjectMapper() {
return new ObjectMapper().registerModule(new JavaTimeModule());
}
+
+ @Bean
+ @ConditionalOnBean(ReactiveClientRegistrationRepository.class)
+ public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
+ ReactiveClientRegistrationRepository clientRegistrationRepository,
+ ReactiveOAuth2AuthorizedClientService authorizedClientService) {
+ var authorizedClientManager =
+ new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
+ clientRegistrationRepository, authorizedClientService);
+
+ var authorizedClientProvider =
+ ReactiveOAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build();
+
+ authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
+ return authorizedClientManager;
+ }
}
diff --git a/util/src/main/java/care/smith/fts/util/WebClientFactory.java b/util/src/main/java/care/smith/fts/util/WebClientFactory.java
index 41b03c18..72a8c765 100644
--- a/util/src/main/java/care/smith/fts/util/WebClientFactory.java
+++ b/util/src/main/java/care/smith/fts/util/WebClientFactory.java
@@ -6,6 +6,7 @@
import care.smith.fts.util.auth.HttpClientAuth;
import care.smith.fts.util.auth.HttpClientBasicAuth;
import care.smith.fts.util.auth.HttpClientCookieTokenAuth;
+import care.smith.fts.util.auth.HttpClientOAuth2Auth;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientSsl;
import org.springframework.context.annotation.Import;
@@ -14,22 +15,25 @@
import org.springframework.web.reactive.function.client.WebClient.Builder;
@Component
-@Import({HttpClientBasicAuth.class, HttpClientCookieTokenAuth.class})
+@Import({HttpClientBasicAuth.class, HttpClientOAuth2Auth.class, HttpClientCookieTokenAuth.class})
public class WebClientFactory {
private final Builder clientBuilder;
private final WebClientSsl ssl;
private final HttpClientBasicAuth basic;
+ private final HttpClientOAuth2Auth oauth2;
private final HttpClientCookieTokenAuth token;
public WebClientFactory(
WebClient.Builder clientBuilder,
WebClientSsl ssl,
@Autowired(required = false) HttpClientBasicAuth basic,
+ @Autowired(required = false) HttpClientOAuth2Auth oauth2,
@Autowired(required = false) HttpClientCookieTokenAuth token) {
this.clientBuilder = clientBuilder;
this.ssl = ssl;
this.basic = basic;
+ this.oauth2 = oauth2;
this.token = token;
}
@@ -55,6 +59,8 @@ private void configureAuth(Builder builder, HttpClientAuth.Config auth) {
configureAuth(builder, "basic", basic, auth.basic());
} else if (auth.cookieToken() != null) {
configureAuth(builder, "cookieToken", token, auth.cookieToken());
+ } else if (auth.oauth2() != null) {
+ configureAuth(builder, "cookieToken", oauth2, auth.oauth2());
}
}
}
diff --git a/util/src/main/java/care/smith/fts/util/auth/HttpClientAuth.java b/util/src/main/java/care/smith/fts/util/auth/HttpClientAuth.java
index 5b866ebb..991eec9b 100644
--- a/util/src/main/java/care/smith/fts/util/auth/HttpClientAuth.java
+++ b/util/src/main/java/care/smith/fts/util/auth/HttpClientAuth.java
@@ -10,6 +10,7 @@ public interface HttpClientAuth {
record Config(
HttpClientBasicAuth.Config basic,
+ HttpClientOAuth2Auth.Config oauth2,
HttpClientCookieTokenAuth.Config cookieToken,
Object none) {
diff --git a/util/src/main/java/care/smith/fts/util/auth/HttpClientOAuth2Auth.java b/util/src/main/java/care/smith/fts/util/auth/HttpClientOAuth2Auth.java
new file mode 100644
index 00000000..ce067cdf
--- /dev/null
+++ b/util/src/main/java/care/smith/fts/util/auth/HttpClientOAuth2Auth.java
@@ -0,0 +1,35 @@
+package care.smith.fts.util.auth;
+
+import care.smith.fts.util.auth.HttpClientOAuth2Auth.Config;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
+import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient.Builder;
+
+/** OAuth2 Authentication using oauth2 client credentials flow. */
+@Slf4j
+@Component
+@ConditionalOnBean(ReactiveOAuth2AuthorizedClientManager.class)
+public class HttpClientOAuth2Auth implements HttpClientAuth {
+
+ private final ReactiveOAuth2AuthorizedClientManager clientManager;
+
+ public HttpClientOAuth2Auth(ReactiveOAuth2AuthorizedClientManager clientManager) {
+ this.clientManager = clientManager;
+ }
+
+ public record Config(String registration) {}
+
+ @Override
+ public void configure(Config config, Builder builder) {
+ log.debug(
+ "Configuring oauth2 client, registration '{}', clientManager: {}",
+ config.registration(),
+ clientManager);
+ var filter = new ServerOAuth2AuthorizedClientExchangeFilterFunction(clientManager);
+ filter.setDefaultClientRegistrationId(config.registration());
+ builder.filter(filter);
+ }
+}
diff --git a/util/src/main/java/care/smith/fts/util/auth/HttpServerAuthMethod.java b/util/src/main/java/care/smith/fts/util/auth/HttpServerAuthMethod.java
index 46210476..74d647b7 100644
--- a/util/src/main/java/care/smith/fts/util/auth/HttpServerAuthMethod.java
+++ b/util/src/main/java/care/smith/fts/util/auth/HttpServerAuthMethod.java
@@ -16,7 +16,10 @@ default ServerHttpSecurity filter(Endpoint endpoint, ServerHttpSecurity http) {
ReactiveUserDetailsService configureUsers();
record AuthMethod(
- HttpServerClientCertAuth clientCert, HttpServerBasicAuth basic, HttpServerNoneAuth none) {}
+ HttpServerClientCertAuth clientCert,
+ HttpServerBasicAuth basic,
+ HttpServerOAuth2Auth oauth2,
+ HttpServerNoneAuth none) {}
- AuthMethod NONE = new AuthMethod(null, null, HttpServerNoneAuth.NONE);
+ AuthMethod NONE = new AuthMethod(null, null, null, HttpServerNoneAuth.NONE);
}
diff --git a/util/src/main/java/care/smith/fts/util/auth/HttpServerOAuth2Auth.java b/util/src/main/java/care/smith/fts/util/auth/HttpServerOAuth2Auth.java
new file mode 100644
index 00000000..e4a48e56
--- /dev/null
+++ b/util/src/main/java/care/smith/fts/util/auth/HttpServerOAuth2Auth.java
@@ -0,0 +1,33 @@
+package care.smith.fts.util.auth;
+
+import static org.springframework.security.oauth2.jwt.ReactiveJwtDecoders.fromIssuerLocation;
+
+import care.smith.fts.util.auth.HttpServerAuthConfig.Endpoint;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+
+public record HttpServerOAuth2Auth(String issuer) implements HttpServerAuthMethod {
+
+ @Override
+ public ServerHttpSecurity configure(ServerHttpSecurity http) {
+ return http.oauth2ResourceServer(
+ oauth2 -> oauth2.jwt(jwt -> jwt.jwtDecoder(fromIssuerLocation(issuer))));
+ }
+
+ @Override
+ public ServerHttpSecurity filter(Endpoint endpoint, ServerHttpSecurity http) {
+ return http.authorizeExchange(
+ exchange -> exchange.pathMatchers(endpoint.path()).hasAuthority(endpoint.role()));
+ }
+
+ @Override
+ public ReactiveUserDetailsService configureUsers() {
+ // Since it's client credentials, we don't configure users explicitly here.
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return "OAuth2";
+ }
+}
diff --git a/util/src/test/java/care/smith/fts/util/WebClientFactoryTest.java b/util/src/test/java/care/smith/fts/util/WebClientFactoryTest.java
index 92a8575b..2b93962a 100644
--- a/util/src/test/java/care/smith/fts/util/WebClientFactoryTest.java
+++ b/util/src/test/java/care/smith/fts/util/WebClientFactoryTest.java
@@ -10,6 +10,7 @@
import care.smith.fts.util.auth.HttpClientAuth.Config;
import care.smith.fts.util.auth.HttpClientBasicAuth;
import care.smith.fts.util.auth.HttpClientCookieTokenAuth;
+import care.smith.fts.util.auth.HttpClientOAuth2Auth;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -28,8 +29,9 @@ class WebClientFactoryTest {
void setUp(
@Autowired WebClientSsl ssl,
@Autowired HttpClientBasicAuth basic,
+ @Autowired HttpClientOAuth2Auth oauth2,
@Autowired HttpClientCookieTokenAuth token) {
- factory = new WebClientFactory(builder(), ssl, basic, token);
+ factory = new WebClientFactory(builder(), ssl, basic, oauth2, token);
}
@Test
@@ -56,7 +58,7 @@ void createWithMultipleAuthBasicIsTaken() {
var basic = Mockito.mock(HttpClientBasicAuth.class);
var token = Mockito.mock(HttpClientCookieTokenAuth.class);
Builder builder = builder();
- factory = new WebClientFactory(builder, null, basic, token);
+ factory = new WebClientFactory(builder, null, basic, null, token);
var basicConf = new HttpClientBasicAuth.Config("user-1505512", "pwd-15054518");
var tokenConf = new HttpClientCookieTokenAuth.Config("token-152510");
@@ -70,7 +72,7 @@ void createWithMultipleAuthBasicIsTaken() {
@Test
void createWithBasicAuthMissingImplementation(
@Autowired WebClientSsl ssl, @Autowired HttpClientCookieTokenAuth token) {
- factory = new WebClientFactory(builder(), ssl, null, token);
+ factory = new WebClientFactory(builder(), ssl, null, null, token);
var auth = new HttpClientBasicAuth.Config("user-144512", "pwd-144538");
var config = new HttpClientConfig("http://localhost", new Config(auth, null));
@@ -88,7 +90,7 @@ void createWithTokenAuth() {
@Test
void createWithTokenAuthMissingImplementation(
@Autowired WebClientSsl ssl, @Autowired HttpClientBasicAuth basic) {
- factory = new WebClientFactory(builder(), ssl, basic, null);
+ factory = new WebClientFactory(builder(), ssl, basic, null, null);
var auth = new HttpClientCookieTokenAuth.Config("token-146520");
var config = new HttpClientConfig("http://localhost", new Config(null, auth));
diff --git a/util/src/test/java/care/smith/fts/util/auth/HttpServerAuthConfigTest.java b/util/src/test/java/care/smith/fts/util/auth/HttpServerAuthConfigTest.java
index 58abecf8..af4151be 100644
--- a/util/src/test/java/care/smith/fts/util/auth/HttpServerAuthConfigTest.java
+++ b/util/src/test/java/care/smith/fts/util/auth/HttpServerAuthConfigTest.java
@@ -23,7 +23,10 @@ void multipleAuthMethodsThrow() {
var config = new HttpServerAuthConfig();
config.setAuth(
new AuthMethod(
- null, new HttpServerBasicAuth(List.of()), new HttpServerNoneAuth()));
+ null,
+ new HttpServerBasicAuth(List.of()),
+ new HttpServerOAuth2Auth(""),
+ new HttpServerNoneAuth()));
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(config::userDetailsService)
@@ -33,7 +36,7 @@ void multipleAuthMethodsThrow() {
@Test
void noAuthMethodDefaultsToNone() {
var config = new HttpServerAuthConfig();
- config.setAuth(new AuthMethod(null, null, null));
+ config.setAuth(new AuthMethod(null, null, null, null));
var reactiveUserDetailsService = config.userDetailsService();