diff --git a/pom.xml b/pom.xml
index 869baf6..9e8bc54 100644
--- a/pom.xml
+++ b/pom.xml
@@ -69,6 +69,17 @@
org.springframework.boot
spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-client
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+
org.springframework.security
spring-security-test
@@ -192,6 +203,17 @@
jgrapht-core
${jgrapht-core.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-authorization-server
+
diff --git a/src/main/java/de/samply/converter/ConverterManager.java b/src/main/java/de/samply/converter/ConverterManager.java
index f38e01d..d476c55 100644
--- a/src/main/java/de/samply/converter/ConverterManager.java
+++ b/src/main/java/de/samply/converter/ConverterManager.java
@@ -20,6 +20,8 @@ public ConverterManager(
@Autowired ApplicationContext applicationContext,
@Value(ExporterConst.CONVERTER_XML_APPLICATION_CONTEXT_PATH_SV) String converterXmlApplicationContextPath
) {
+ System.out.println("Context path: " + converterXmlApplicationContextPath);
+ System.out.println("Converter XML application context: " + applicationContext);
List converters = new ArrayList<>();
converters.addAll(applicationContext.getBeansOfType(Converter.class).values());
converters.addAll(fetchConvertersFromApplicationContextInFile(converterXmlApplicationContextPath,
diff --git a/src/main/java/de/samply/exporter/ExporterConst.java b/src/main/java/de/samply/exporter/ExporterConst.java
index 6a95b70..3567215 100644
--- a/src/main/java/de/samply/exporter/ExporterConst.java
+++ b/src/main/java/de/samply/exporter/ExporterConst.java
@@ -2,10 +2,32 @@
public class ExporterConst {
+ // Spring Values (SV)
+ // public final static String HEAD_SV = "${";
+ // public final static String BOTTOM_SV = "}";
+
public final static boolean LOG_FHIR_VALIDATION_DEFAULT = false;
// HTTP Headers
- public final static String API_KEY_HEADER = "x-api-key";
+ public final static String API_KEY_HEADER = "Authorization";
+ public static final String API_KEY_PREFIX = "ApiKey";
+ public final static String SECURITY_ENABLED = "SECURITY_ENABLED";
+ public final static String JWKS_URI_PROPERTY = "spring.security.oauth2.client.provider.oidc.jwk-set-uri";
+
+
+ // Keycloak paths
+ public final static String FETCH_USER_ID_KEYCLOAK_PATH = "/admin/realms/{realm}/users?email={email}";
+ public final static String FETCH_GROUP_ID_KEYCLOAK_PATH = "/admin/realms/{realm}/groups?search={group}";
+ public final static String CHANGE_USER_GROUP_KEYCLOAK_PATH = "/admin/realms/{realm}/users/{user-id}/groups/{group-id}";
+ public final static String FETCH_TOKEN_KEYCLOAK_PATH = "/realms/{realm}/protocol/openid-connect/token";
+
+ // Keycloak parameters
+ public final static String CLIENT_ID_KEYCLOAK_PARAM = "client_id";
+ public final static String CLIENT_SECRET_KEYCLOAK_PARAM = "client_secret";
+ public final static String GRANT_TYPE_KEYCLOAK_PARAM = "grant_type";
+ public final static String CLIENT_CREDENTIALS_KEYCLOAK_CONST = "client_credentials";
+ public final static String ACCES_TOKEN_KEYCLOAK_CONST = "access_token";
+ public final static String ID_KEYCLOAK_CONST = "id";
// Token variables
public final static String TOKEN_HEAD = "${";
@@ -173,10 +195,14 @@ public class ExporterConst {
public static final String TEMPLATE_GRAPH = "/template-graph";
public static final String RUNNING_QUERIES = "/running-queries";
public static final String API_DOCS = "/api-docs";
+ public static final String ERROR = "/error";
- public static final String[] REST_PATHS_WITH_API_KEY = new String[]{CREATE_QUERY, FETCH_QUERIES,
+ public static final String[] REST_PATHS_WITH_AUTH = new String[]{CREATE_QUERY, FETCH_QUERIES,
FETCH_QUERY_EXECUTIONS, FETCH_QUERY_EXECUTION_ERRORS, REQUEST, ACTIVE_INQUIRIES, ARCHIVED_INQUIRIES,
- ERROR_INQUIRIES, INQUIRY, ARCHIVE_QUERY, STATUS, LOGS, RUNNING_QUERIES, UPDATE_QUERY, API_DOCS};
+ ERROR_INQUIRIES, INQUIRY, ARCHIVE_QUERY, LOGS, RUNNING_QUERIES, UPDATE_QUERY};
+ public static final String[] REST_PATHS_NO_AUTH=new String[]{INFO, API_DOCS,
+ STATUS, ERROR};
+ public static final String[] REST_PATHS_BROWSER_AUTH=new String[]{RESPONSE, "/oauth2/**", "/login/**"};
// TODO: RESPONSE ??? Only with UUID enough?
// REST Headers
@@ -254,4 +280,15 @@ public class ExporterConst {
public final static String QUERY_CONTEXT_EQUAL = "=";
public final static String FHIR_SEARCH_PATH_ROOT = "ROOT";
+ // Filter
+ public final static String SECURITY_ENABLED_SV = HEAD_SV + SECURITY_ENABLED + ":true" + BOTTOM_SV;
+ public final static String JWKS_URI_PROPERTY_SV = HEAD_SV + JWKS_URI_PROPERTY + BOTTOM_SV;
+
+ // UUser and Roles
+ public final static String TEST_EMAIL = "test@project-manager.com";
+ public final static String TEST_BRIDGEHEAD = "bridgehead-test";
+ public final static String JWT_GROUPS_CLAIM = "JWT_GROUPS_CLAIM";
+ public final static String JWT_GROUPS_CLAIM_SV = HEAD_SV + JWT_GROUPS_CLAIM + ":groups" + BOTTOM_SV;
+
+
}
diff --git a/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java b/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java
index 0b124e1..e21318b 100644
--- a/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java
+++ b/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java
@@ -15,34 +15,35 @@
@Component
public class ApiKeyAuthenticationManager implements AuthenticationManager {
- /*
- * Security: This class provides API key support to REST for connecting different server
- */
-
- @Value(ExporterConst.EXPORTER_API_KEY_SV)
- private String apiKey;
-
- /**
- * Authenticates request based on an API key.
- *
- * @param authentication REST API Client authentication.
- * @return Authentication with setAuthenticated set to true or false.
- * @throws AuthenticationException Authentication exception.
- */
- @Override
- public Authentication authenticate(Authentication authentication) throws AuthenticationException {
-
- String apiKey = (String) authentication.getPrincipal();
-
- if (!ObjectUtils.isEmpty(this.apiKey) && (ObjectUtils.isEmpty(apiKey) || !apiKey.equals(
- this.apiKey))) {
- throw new BadCredentialsException("Incorrect API Key");
- } else {
- authentication.setAuthenticated(true);
+ /*
+ * Security: This class provides API key support to REST for connecting different server
+ */
+
+ @Value(ExporterConst.EXPORTER_API_KEY_SV)
+ private String apiKey;
+
+ /**
+ * Authenticates request based on an API key.
+ *
+ * @param authentication REST API Client authentication.
+ * @return Authentication with setAuthenticated set to true or false.
+ * @throws AuthenticationException Authentication exception.
+ */
+ @Override
+ public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+ String apiKey = (String) authentication.getPrincipal();
+ // new Authorization header with prefix ApiKey
+ if (apiKey != null && apiKey.startsWith(ExporterConst.API_KEY_PREFIX)) {
+ apiKey = apiKey.substring(ExporterConst.API_KEY_PREFIX.length()).trim();
+ } else {
+ throw new BadCredentialsException("header does not contain expected prefix");
+ }
+ if (!ObjectUtils.isEmpty(this.apiKey) && !apiKey.equals(
+ this.apiKey)) {
+ throw new BadCredentialsException("Incorrect API Key");
+ } else {
+ authentication.setAuthenticated(true);
+ }
+ return authentication;
}
-
- return authentication;
-
- }
-
-}
+}
\ No newline at end of file
diff --git a/src/main/java/de/samply/security/ApiKeyRequestMatcher.java b/src/main/java/de/samply/security/ApiKeyRequestMatcher.java
new file mode 100644
index 0000000..5c4718a
--- /dev/null
+++ b/src/main/java/de/samply/security/ApiKeyRequestMatcher.java
@@ -0,0 +1,18 @@
+package de.samply.security;
+
+import de.samply.exporter.ExporterConst;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.StringUtils;
+
+public class ApiKeyRequestMatcher implements RequestMatcher {
+ @Override
+ public boolean matches(HttpServletRequest request) {
+ String auth = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (StringUtils.hasText(auth) && auth.startsWith(ExporterConst.API_KEY_PREFIX)) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java
index 9642a45..ed43000 100644
--- a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java
+++ b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java
@@ -1,21 +1,25 @@
package de.samply.security;
-
import de.samply.exporter.ExporterConst;
+import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpHeaders;
import org.springframework.security.config.Customizer;
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.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
+import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
+import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
+import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
-import org.springframework.web.cors.CorsConfiguration;
-import org.springframework.web.cors.CorsConfigurationSource;
-import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.security.web.util.matcher.OrRequestMatcher;
import java.util.Arrays;
@@ -30,7 +34,6 @@ public class ApiKeySecurityConfiguration {
private ApiKeyAuthenticationManager apiKeyAuthenticationManager;
-
/**
* Add API key filter to Spring http security.
*
@@ -38,22 +41,30 @@ public class ApiKeySecurityConfiguration {
* @return Security Filter Chain based on apiKey.
* @throws Exception Exception.
*/
- @Bean
- public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
+ @Bean(name = "apiKeyFilterChain")
+ public SecurityFilterChain apiFilterChain(HttpSecurity httpSecurity) throws Exception {
+ OrRequestMatcher pathMatcher = new OrRequestMatcher(
+ Arrays.stream(ExporterConst.REST_PATHS_WITH_AUTH)
+ .map(AntPathRequestMatcher::new)
+ .toArray(org.springframework.security.web.util.matcher.RequestMatcher[]::new)
+ );
+ AndRequestMatcher apiKeyOnPaths = new AndRequestMatcher(new ApiKeyRequestMatcher(), pathMatcher);
+
httpSecurity
+ .securityMatcher(apiKeyOnPaths)
.cors(Customizer.withDefaults())
- .securityMatcher(ExporterConst.REST_PATHS_WITH_API_KEY)
.csrf(csrf -> csrf.disable())
.sessionManagement(httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .addFilter(createApiKeyFilter())
- .authorizeHttpRequests(authorize -> {
- authorize.requestMatchers(new AntPathRequestMatcher(ExporterConst.API_DOCS)).permitAll();
- Arrays.stream(ExporterConst.REST_PATHS_WITH_API_KEY).forEach(path -> authorize.requestMatchers(new AntPathRequestMatcher(path)).authenticated());
- authorize.anyRequest().authenticated();
- }
- );
-
+ .addFilterBefore(createApiKeyFilter(), BearerTokenAuthenticationFilter.class)
+ .authorizeHttpRequests(authz -> authz
+ .anyRequest().authenticated()
+ )
+ .exceptionHandling(ex -> ex
+ .authenticationEntryPoint(apiKeyAuthEntryPoint())
+ .accessDeniedHandler(apiKeyAccessDeniedHandler())
+ )
+ .anonymous(anon -> anon.disable());
return httpSecurity.build();
}
@@ -65,27 +76,47 @@ public void setApiKeyAuthenticationManager(
@Bean
public ApiKeyFilter createApiKeyFilter() {
-
ApiKeyFilter apiKeyFilter = new ApiKeyFilter();
apiKeyFilter.setAuthenticationManager(apiKeyAuthenticationManager);
return apiKeyFilter;
-
}
@Bean
- CorsConfigurationSource corsConfigurationSource(
- @Value(ExporterConst.CROSS_ORIGINS_SV) String[] crossOrigins) {
- CorsConfiguration configuration = new CorsConfiguration();
- //configuration.setAllowedOrigins(fetchCrossOrigins(crossOrigins));
- configuration.setAllowedOrigins(Arrays.asList(crossOrigins));
- configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT"));
- configuration.setAllowedHeaders(
- Arrays.asList("Authorization", "Cache-Control", "Content-Type", "Origin",
- ExporterConst.API_KEY_HEADER));
- UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
- source.registerCorsConfiguration("/**", configuration);
- return source;
+ public AuthenticationEntryPoint apiKeyAuthEntryPoint() {
+ return (request, response, authException) -> {
+ response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "ApiKey realm=\"Exporter\"");
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json;charset=UTF-8");
+ String body = """
+ {
+ "error": "unauthorized",
+ "error_description": "API key missing or invalid",
+ "status": 401,
+ "path": "%s",
+ "timestamp": "%s"
+ }
+ """.formatted(request.getRequestURI(), java.time.OffsetDateTime.now().toString());
+
+ response.getWriter().write(body);
+ };
}
-
+ @Bean
+ public AccessDeniedHandler apiKeyAccessDeniedHandler() {
+ return (request, response, accessDeniedException) -> {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("application/json;charset=UTF-8");
+ String body = """
+ {
+ "error": "forbidden",
+ "error_description": "You do not have permission to access this resource",
+ "status": 403,
+ "path": "%s",
+ "timestamp": "%s"
+ }
+ """.formatted(request.getRequestURI(), java.time.OffsetDateTime.now().toString());
+
+ response.getWriter().write(body);
+ };
+ }
}
diff --git a/src/main/java/de/samply/security/CommonSecurityBeans.java b/src/main/java/de/samply/security/CommonSecurityBeans.java
new file mode 100644
index 0000000..dbc9365
--- /dev/null
+++ b/src/main/java/de/samply/security/CommonSecurityBeans.java
@@ -0,0 +1,96 @@
+package de.samply.security;
+
+import de.samply.exporter.ExporterConst;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtDecoders;
+import org.springframework.security.oauth2.jwt.JwtException;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import java.util.Arrays;
+
+
+@Configuration
+public class CommonSecurityBeans {
+ private static final Logger log = LoggerFactory.getLogger(CommonSecurityBeans.class);
+
+ @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
+ private String issuerUri;
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource(
+ @Value(ExporterConst.CROSS_ORIGINS_SV) String[] crossOrigins) {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(Arrays.asList(crossOrigins));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS"));
+ configuration.setAllowedHeaders(
+ Arrays.asList("Authorization", "Cache-Control", "Content-Type", "Origin"));
+ configuration.setAllowCredentials(true); // Optional: allow credentials
+ configuration.setExposedHeaders(Arrays.asList("Content-Disposition")); // Optional
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+
+ @Bean
+ public AuthenticationEntryPoint jwtAuthEntryPoint() {
+ return (request, response, authException) -> {
+ Throwable cause = authException.getCause();
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json");
+
+ if (cause instanceof JwtException jwtEx && jwtEx.getMessage().contains("expired")) {
+ response.getWriter().write("""
+ {"error":"token_expired","message":"Your access token has expired. Please refresh or log in again."}
+ """);
+ } else {
+ response.getWriter().write("""
+ {"error":"unauthorized","message":"Invalid or missing access token."}
+ """);
+ }
+ };
+ }
+
+ /**
+ * Creates a {@link JwtDecoder} bean using the configured issuer URI.
+ * @return a configured {@link JwtDecoder} instance
+ */
+ @Bean
+ public JwtDecoder jwtDecoder() {
+ JwtDecoder delegate = JwtDecoders.fromIssuerLocation(issuerUri);
+
+ return token -> {
+ try {
+ Jwt jwt = delegate.decode(token);
+ log.debug("[JWT OK] sub=" + jwt.getSubject()
+ + " aud=" + jwt.getAudience()
+ + " iss=" + jwt.getIssuer()
+ + " exp=" + jwt.getExpiresAt());
+ return jwt;
+ } catch (JwtException e) {
+ log.warn("[JWT FAIL] {}", e.getMessage());
+ throw e;
+ }
+ catch (Exception e) {
+ throw new JwtException( "[JWT FAIL] jwt is not valid.");
+ }
+ };
+ }
+
+ /**
+ *
+ * @return
+ */
+ @Bean
+ public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter();}
+}
diff --git a/src/main/java/de/samply/security/GroupAuthManager.java b/src/main/java/de/samply/security/GroupAuthManager.java
new file mode 100644
index 0000000..73e7e32
--- /dev/null
+++ b/src/main/java/de/samply/security/GroupAuthManager.java
@@ -0,0 +1,79 @@
+package de.samply.security;
+
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+@Configuration
+public class GroupAuthManager {
+ private static final Logger log = LoggerFactory.getLogger(GroupAuthManager.class);
+ @Value("${OIDC_GROUPS:}")
+ private String allowedGroupsEnv;
+
+ @PostConstruct
+ public void init() {
+ if (allowedGroupsEnv == null || allowedGroupsEnv.trim().isEmpty()) {
+ throw new IllegalStateException("Allowed groups are not configured properly.");
+ }
+ log.info("Allowed groups: " + allowedGroupsEnv);
+ }
+
+ @Bean
+ public AuthorizationManager groupAuthorizationManager() {
+ List allowedGroups = Arrays.stream(allowedGroupsEnv.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toList();
+
+ return (authentication, context) -> {
+ Authentication auth = authentication.get();
+ List userGroups = null;
+
+ if (auth instanceof JwtAuthenticationToken jwtAuth) {
+ Jwt jwt = jwtAuth.getToken();
+ userGroups = jwt.getClaimAsStringList("groups");
+ }
+
+ else if (auth instanceof OAuth2AuthenticationToken oauth2Auth) {
+ Object principal = oauth2Auth.getPrincipal();
+
+ if (principal instanceof OidcUser oidcUser) {
+ userGroups = oidcUser.getClaimAsStringList("groups");
+ } else if (principal instanceof OAuth2User oauth2User) {
+ Object claim = oauth2User.getAttributes().get("groups");
+ if (claim instanceof List> list) {
+ userGroups = list.stream()
+ .filter(String.class::isInstance)
+ .map(String.class::cast)
+ .toList();
+ }
+ }
+ }
+
+ boolean isAuthorized = userGroups != null && userGroups.stream().anyMatch(allowedGroups::contains);
+
+ log.debug("Auth type : {}", auth.getClass().getSimpleName());
+ log.debug("User groups : {}", userGroups);
+ log.debug("Allowed groups : {}", allowedGroups);
+ log.debug("Has allowed group: {}", isAuthorized);
+
+ return new AuthorizationDecision(isAuthorized);
+ };
+ }
+}
diff --git a/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java b/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java
new file mode 100644
index 0000000..9bf5712
--- /dev/null
+++ b/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java
@@ -0,0 +1,36 @@
+package de.samply.security;
+
+import de.samply.exporter.ExporterConst;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.OrRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+
+import java.util.Arrays;
+
+@Configuration
+@EnableWebSecurity
+@Order(0)
+public class NoAuthSecurityConfiguration {
+ @Bean
+ SecurityFilterChain publicChain(HttpSecurity httpSecurity) throws Exception {
+ var publicPaths = new OrRequestMatcher(
+ Arrays.stream(ExporterConst.REST_PATHS_NO_AUTH)
+ .map(AntPathRequestMatcher::new)
+ .toArray(RequestMatcher[]::new)
+ );
+
+ return httpSecurity.securityMatcher(publicPaths)
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/de/samply/security/OAuthSecurityConfiguration.java b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java
new file mode 100644
index 0000000..c4cd851
--- /dev/null
+++ b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java
@@ -0,0 +1,143 @@
+package de.samply.security;
+
+import de.samply.exporter.ExporterConst;
+import jakarta.annotation.PostConstruct;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.authorization.AuthenticatedAuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManagers;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
+import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
+import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
+import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
+import org.springframework.security.web.savedrequest.RequestCache;
+import org.springframework.security.web.savedrequest.SavedRequest;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
+import org.springframework.security.web.util.matcher.OrRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ *
+ */
+@Configuration
+@EnableWebSecurity
+@Order(2)
+public class OAuthSecurityConfiguration {
+ private static final Logger log = LoggerFactory.getLogger(OAuthSecurityConfiguration.class);
+ @Value(ExporterConst.SECURITY_ENABLED_SV)
+ private boolean isSecurityEnabled;
+
+
+ /**
+ * Filter Chain for the Exporter
+ * If security is enabled, the application is configured as an OAuth2 resource server that:
+ *
+ * - Disables CSRF protection (suitable for stateless REST APIs).
+ * - Uses JWT tokens for authentication via a custom decoder and authentication converter.
+ * - Applies access control via a custom {@link AuthorizationManager}.
+ *
+ *
+ * @param http the {@link HttpSecurity} to modify
+ * @return the configured {@link SecurityFilterChain}
+ * @throws Exception if an error occurs while configuring the security filter chain
+ */
+ @Bean(name = "oauthFilterChain")
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity httpSecurity,
+ AuthenticationEntryPoint jwtAuthEntryPoint,
+ JwtDecoder jwtDecoder,
+ JwtAuthenticationConverter jwtAuthenticationConverter,
+ AuthorizationManager groupAuthorizationManager
+ ) throws Exception {
+
+ RequestMatcher browserPaths = new OrRequestMatcher(
+ Arrays.stream(ExporterConst.REST_PATHS_BROWSER_AUTH)
+ .map(AntPathRequestMatcher::new)
+ .toArray(RequestMatcher[]::new)
+ );
+
+ AuthorizationManager authThenGroups =
+ AuthorizationManagers.allOf(
+ AuthenticatedAuthorizationManager.authenticated(),
+ groupAuthorizationManager
+ );
+
+ httpSecurity
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(Customizer.withDefaults())
+ .requestCache(cache -> cache.requestCache(new HttpSessionRequestCache()))
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
+ .oauth2ResourceServer(oauth2 -> oauth2
+ .jwt(jwt -> jwt.decoder(jwtDecoder)
+ .jwtAuthenticationConverter(jwtAuthenticationConverter))
+ .authenticationEntryPoint(jwtAuthEntryPoint)
+ )
+ .oauth2Login(oauth2Login -> oauth2Login
+ .loginPage("/oauth2/authorization/oidc")
+ )
+ .authorizeHttpRequests(authz -> authz
+ .requestMatchers("/oauth2/**", "/login/**").permitAll()
+ .requestMatchers(ExporterConst.REST_PATHS_NO_AUTH).permitAll()
+ .anyRequest().access(authThenGroups) // <- use composed manager
+ )
+ .exceptionHandling(eh -> eh
+ .defaultAuthenticationEntryPointFor(
+ new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/oidc"),
+ browserPaths
+ )
+ .defaultAuthenticationEntryPointFor(
+ jwtAuthEntryPoint,
+ AnyRequestMatcher.INSTANCE
+ )
+ .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
+ );
+
+ return httpSecurity.build();
+ }
+
+ private AuthenticationSuccessHandler successHandler() {
+ return new SimpleUrlAuthenticationSuccessHandler() {
+ private RequestCache requestCache = new HttpSessionRequestCache();
+
+ @Override
+ public void onAuthenticationSuccess(
+ HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
+ SavedRequest savedRequest = requestCache.getRequest(request, response);
+ setUseReferer(true);
+ if (savedRequest != null) {
+ String targetUrl = savedRequest.getRedirectUrl();
+ getRedirectStrategy().sendRedirect(request, response, targetUrl);
+ } else {
+ super.onAuthenticationSuccess(request, response, authentication);
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 49d832c..278e336 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,6 +1,9 @@
server.port=${APPLICATION_PORT:8092}
logging.level.root=${LOG_LEVEL:INFO}
-logging.level.org.springframework.security=${LOG_LEVEL:INFO}
+logging.level.org.springframework.security=TRACE
+logging.level.org.springframework.security.oauth2=TRACE
+logging.level.org.springframework.web.client.RestTemplate=TRACE
+logging.level.org.springframework.web=DEBUG
logging.level.ca.uhn.fhir=${HAPI_FHIR_CLIENT_LOG_LEVEL:OFF}
logging.level.org.hl7.fhir=${HAPI_FHIR_CLIENT_LOG_LEVEL:OFF}
@@ -29,3 +32,20 @@ spring.jpa.show-sql=${HIBERNATE_LOG:false}
springdoc.api-docs.path=/api-docs
springdoc.api-docs.enabled=true
springdoc.swagger-ui.path=/swagger-ui.html
+
+# security OIDC config
+spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_URL}/application/o/${OIDC_SLUG}/
+spring.security.oauth2.client.provider.oidc.authorization-uri=${OIDC_URL}/application/o/authorize/
+spring.security.oauth2.client.provider.oidc.token-uri=${OIDC_URL}/application/o/token/
+spring.security.oauth2.client.provider.oidc.user-info-uri=${OIDC_URL}/application/o/userinfo/
+spring.security.oauth2.client.provider.oidc.jwk-set-uri=${OIDC_URL}/application/o/${OIDC_SLUG}/jwks/
+spring.security.oauth2.client.provider.oidc.user-name-attribute=email
+
+spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID}
+spring.security.oauth2.client.registration.oidc.client-secret=${OIDC_CLIENT_SECRET:}
+spring.security.oauth2.client.registration.oidc.authorization-grant-type=authorization_code
+spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
+spring.security.oauth2.client.registration.oidc.scope=email,profile,openid
+server.servlet.session.cookie.same-site=lax
+server.servlet.session.cookie.secure=false
+server.servlet.session.cookie.http-only=true
\ No newline at end of file