diff --git a/it/pom.xml b/it/pom.xml
new file mode 100644
index 0000000..cd8d0db
--- /dev/null
+++ b/it/pom.xml
@@ -0,0 +1,115 @@
+
+
+ 4.0.0
+
+
+ de.platesoft
+ plate-auth-parent
+ ${revision}
+ ../pom.xml
+
+
+ plate-auth-integration-tests
+ plate-auth-integration-tests
+ Greenfield consumer integration tests for plate-auth-starter
+
+
+
+
+ de.platesoft
+ plate-auth-starter
+ ${revision}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+
+
+ com.h2database
+ h2
+ test
+
+
+ org.testcontainers
+ postgresql
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+ org.postgresql
+ postgresql
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ default-jar
+ none
+
+
+
+
+ org.apache.maven.plugins
+ maven-install-plugin
+
+ true
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+
+ true
+
+
+
+
+
diff --git a/it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java b/it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java
new file mode 100644
index 0000000..3c13d06
--- /dev/null
+++ b/it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java
@@ -0,0 +1,53 @@
+package de.platesoft.auth.it;
+
+import de.platesoft.auth.service.ExchangeService;
+import de.platesoft.auth.service.JwtService;
+import de.platesoft.auth.service.MembershipService;
+import de.platesoft.auth.spi.OrgValidator;
+import de.platesoft.auth.spi.defaults.PermissiveOrgValidator;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.ApplicationContext;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * T-IT02: Auto-config wires all required beans with sensible defaults.
+ */
+@SpringBootTest(classes = TestConsumerApplication.class)
+class AuthBootstrapIT {
+
+ @DynamicPropertySource
+ static void properties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", () -> "jdbc:h2:mem:bootstrap_test;DB_CLOSE_DELAY=-1");
+ registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver");
+ registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!");
+ registry.add("plate.auth.exchange.secret", () -> "integration-test-exchange-secret-min32chars!");
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
+ registry.add("spring.jpa.properties.org.hibernate.envers.autoRegisterListeners", () -> "false");
+ registry.add("spring.jpa.properties.hibernate.envers.autoRegisterListeners", () -> "false");
+ registry.add("spring.flyway.enabled", () -> "false");
+ registry.add("plate.auth.flyway.enabled", () -> "false");
+ }
+
+ @Autowired
+ private ApplicationContext ctx;
+
+ @Test
+ void requiredBeansArePresent() {
+ assertNotNull(ctx.getBean(JwtService.class));
+ assertNotNull(ctx.getBean(ExchangeService.class));
+ assertNotNull(ctx.getBean(MembershipService.class));
+ assertNotNull(ctx.getBean(SecurityFilterChain.class));
+ }
+
+ @Test
+ void defaultOrgValidatorIsPermissive() {
+ OrgValidator validator = ctx.getBean(OrgValidator.class);
+ assertInstanceOf(PermissiveOrgValidator.class, validator);
+ }
+}
diff --git a/it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java b/it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java
new file mode 100644
index 0000000..5f81acd
--- /dev/null
+++ b/it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java
@@ -0,0 +1,119 @@
+package de.platesoft.auth.it;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.HexFormat;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * T-IT03: Full exchange flow — sign envelope, POST to /api/auth/exchange, get tokens back.
+ */
+@SpringBootTest(classes = TestConsumerApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ExchangeFlowIT {
+
+ private static final String EXCHANGE_SECRET = "integration-test-exchange-secret-min32chars!";
+
+ @DynamicPropertySource
+ static void properties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", () -> "jdbc:h2:mem:exchange_test;DB_CLOSE_DELAY=-1");
+ registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver");
+ registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!");
+ registry.add("plate.auth.exchange.secret", () -> EXCHANGE_SECRET);
+ registry.add("plate.auth.cors.allowed-origins[0]", () -> "http://localhost");
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
+ registry.add("spring.jpa.properties.org.hibernate.envers.autoRegisterListeners", () -> "false");
+ registry.add("spring.jpa.properties.hibernate.envers.autoRegisterListeners", () -> "false");
+ registry.add("spring.flyway.enabled", () -> "false");
+ registry.add("plate.auth.flyway.enabled", () -> "false");
+ }
+
+ @LocalServerPort
+ private int port;
+
+ private final HttpClient http = HttpClient.newHttpClient();
+
+ @Test
+ void exchangeFlow_validEnvelope_returnsTokens() throws Exception {
+ String nonce = UUID.randomUUID().toString();
+ long iat = Instant.now().getEpochSecond();
+ String body = """
+ {"provider":"google","providerSubject":"google-sub-123","email":"test@example.com","name":"Test User","nonce":"%s","iat":%d}
+ """.formatted(nonce, iat).trim();
+
+ String signature = hmacSha256Hex(body, EXCHANGE_SECRET);
+
+ HttpResponse resp = http.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/api/auth/exchange"))
+ .header("Content-Type", "application/json")
+ .header("X-Exchange-Signature", signature)
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build(), HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("accessToken"));
+ assertTrue(resp.body().contains("refreshToken"));
+ }
+
+ @Test
+ void exchangeFlow_tamperedSignature_returns401() throws Exception {
+ String nonce = UUID.randomUUID().toString();
+ long iat = Instant.now().getEpochSecond();
+ String body = """
+ {"provider":"google","providerSubject":"google-sub-456","email":"tamper@example.com","name":"Tamper","nonce":"%s","iat":%d}
+ """.formatted(nonce, iat).trim();
+
+ HttpResponse resp = http.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/api/auth/exchange"))
+ .header("Content-Type", "application/json")
+ .header("X-Exchange-Signature", "deadbeef0000000000000000000000000000000000000000000000000000000")
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build(), HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(401, resp.statusCode());
+ }
+
+ @Test
+ void exchangeFlow_replayedNonce_rejectsSecondCall() throws Exception {
+ String nonce = UUID.randomUUID().toString();
+ long iat = Instant.now().getEpochSecond();
+ String body = """
+ {"provider":"google","providerSubject":"google-sub-789","email":"replay@example.com","name":"Replay","nonce":"%s","iat":%d}
+ """.formatted(nonce, iat).trim();
+ String signature = hmacSha256Hex(body, EXCHANGE_SECRET);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/api/auth/exchange"))
+ .header("Content-Type", "application/json")
+ .header("X-Exchange-Signature", signature)
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ // First call succeeds
+ HttpResponse first = http.send(request, HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, first.statusCode());
+
+ // Second call with same nonce fails
+ HttpResponse second = http.send(request, HttpResponse.BodyHandlers.ofString());
+ assertEquals(401, second.statusCode());
+ }
+
+ private String hmacSha256Hex(String data, String secret) throws Exception {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
+ }
+}
diff --git a/it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java b/it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java
new file mode 100644
index 0000000..38e89c8
--- /dev/null
+++ b/it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java
@@ -0,0 +1,80 @@
+package de.platesoft.auth.it;
+
+import org.flywaydb.core.Flyway;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import javax.sql.DataSource;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * T-IT01: Flyway migrations V1..V6 apply cleanly on a fresh Postgres.
+ * Requires Docker — skipped locally if TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is not set.
+ * Will run in CI where Docker is available.
+ */
+@Testcontainers
+@SpringBootTest(classes = TestConsumerApplication.class)
+@EnabledIfEnvironmentVariable(named = "CI", matches = ".*", disabledReason = "Requires Docker (CI only)")
+class PlateAuthFlywayMigrationIT {
+
+ @Container
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
+
+ @DynamicPropertySource
+ static void properties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgres::getJdbcUrl);
+ registry.add("spring.datasource.username", postgres::getUsername);
+ registry.add("spring.datasource.password", postgres::getPassword);
+ registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!");
+ registry.add("plate.auth.exchange.secret", () -> "integration-test-exchange-secret-min32chars!");
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
+ registry.add("spring.flyway.enabled", () -> "false");
+ }
+
+ @Autowired
+ private DataSource dataSource;
+
+ @Autowired
+ private Flyway plateAuthFlyway;
+
+ @Test
+ void migrationAppliesV1ThroughV6() throws Exception {
+ try (var conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+
+ ResultSet rs = stmt.executeQuery(
+ "SELECT COUNT(*) FROM flyway_schema_history_auth WHERE success = true");
+ rs.next();
+ int count = rs.getInt(1);
+ assertEquals(6, count, "Expected 6 successful migrations in flyway_schema_history_auth");
+
+ ResultSet tables = stmt.executeQuery(
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' " +
+ "AND table_name IN ('users', 'user_identities', 'memberships', 'invitations', 'access_requests', 'login_events', 'refresh_tokens') " +
+ "ORDER BY table_name");
+ int tableCount = 0;
+ while (tables.next()) tableCount++;
+ assertEquals(7, tableCount, "Expected 7 auth tables created by migrations");
+ }
+ }
+
+ @Test
+ void microsoftTenantIdIndexExists() throws Exception {
+ try (var conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+ ResultSet rs = stmt.executeQuery(
+ "SELECT indexname FROM pg_indexes WHERE indexname = 'idx_user_identities_microsoft_tenant_id'");
+ assertTrue(rs.next(), "Microsoft tenant_id index from V5 should exist");
+ }
+ }
+}
diff --git a/it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java b/it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java
new file mode 100644
index 0000000..587024c
--- /dev/null
+++ b/it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java
@@ -0,0 +1,15 @@
+package de.platesoft.auth.it;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Minimal Spring Boot application that consumes plate-auth-starter.
+ * Used only for integration tests — simulates a greenfield consumer.
+ */
+@SpringBootApplication
+public class TestConsumerApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(TestConsumerApplication.class, args);
+ }
+}
diff --git a/it/src/test/resources/application.yml b/it/src/test/resources/application.yml
new file mode 100644
index 0000000..34f84ed
--- /dev/null
+++ b/it/src/test/resources/application.yml
@@ -0,0 +1,6 @@
+# Test consumer application config — minimal for integration tests
+spring:
+ application:
+ name: plate-auth-it
+ jpa:
+ open-in-view: false
diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java
index d623594..babb444 100644
--- a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java
+++ b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java
@@ -1,10 +1,9 @@
package de.platesoft.auth;
-import de.platesoft.auth.filter.JwtAuthenticationFilter;
-import de.platesoft.auth.service.JwtService;
import de.platesoft.auth.spi.*;
import de.platesoft.auth.spi.defaults.*;
import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -12,22 +11,11 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
-import org.springframework.security.config.http.SessionCreationPolicy;
-import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.security.web.SecurityFilterChain;
-import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
-import org.springframework.web.cors.CorsConfiguration;
-import org.springframework.web.cors.CorsConfigurationSource;
-import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
-
-import java.util.List;
@AutoConfiguration
@EnableConfigurationProperties(PlateAuthProperties.class)
@ComponentScan(basePackages = "de.platesoft.auth")
+@AutoConfigurationPackage(basePackages = "de.platesoft.auth.entity")
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
@EnableAsync
@ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true)
@@ -64,54 +52,4 @@ public class PlateAuthAutoConfiguration {
public OnboardingHook onboardingHook() {
return new NoOpOnboardingHook();
}
-
- // ── Security ─────────────────────────────────────────────────────────────
-
- @Bean
- @ConditionalOnMissingBean(PasswordEncoder.class)
- public PasswordEncoder passwordEncoder() {
- return new BCryptPasswordEncoder();
- }
-
- @Bean
- public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) {
- return new JwtAuthenticationFilter(jwtService);
- }
-
- @Bean
- public SecurityFilterChain plateAuthSecurityFilterChain(
- HttpSecurity http,
- JwtAuthenticationFilter jwtFilter,
- PlateAuthProperties props) throws Exception {
- http
- .csrf(AbstractHttpConfigurer::disable)
- .cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
- .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .authorizeHttpRequests(auth -> auth
- .requestMatchers(
- "/api/auth/exchange",
- "/api/auth/login",
- "/api/auth/register",
- "/api/auth/refresh",
- "/api/auth/config",
- "/actuator/health"
- ).permitAll()
- .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
- .anyRequest().authenticated()
- )
- .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
- return http.build();
- }
-
- private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
- CorsConfiguration config = new CorsConfiguration();
- config.setAllowedOrigins(props.getCors().getAllowedOrigins());
- config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
- config.setAllowedHeaders(List.of("*"));
- config.setAllowCredentials(true);
- config.setMaxAge(3600L);
- UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
- source.registerCorsConfiguration("/**", config);
- return source;
- }
}
diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java
new file mode 100644
index 0000000..4bc9c15
--- /dev/null
+++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java
@@ -0,0 +1,29 @@
+package de.platesoft.auth.config;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * Global exception handler for plate-auth controllers.
+ */
+@RestControllerAdvice(basePackages = "de.platesoft.auth.controller")
+public class PlateAuthExceptionHandler {
+
+ @ExceptionHandler(SecurityException.class)
+ public ResponseEntity handleSecurityException(SecurityException e) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleIllegalArgument(IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(e.getMessage());
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(Exception e) {
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body("Internal error: " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+}
diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java
index f34ac34..e111fd5 100644
--- a/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java
+++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java
@@ -2,6 +2,7 @@ package de.platesoft.auth.config;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,9 +12,12 @@ import javax.sql.DataSource;
* Configures a separate Flyway instance for plate-auth migrations.
* Uses its own history table (flyway_schema_history_auth) to avoid
* version collisions with the consumer application's migrations.
+ *
+ * Disabled when plate.auth.flyway.enabled=false (default: true).
*/
@Configuration
@ConditionalOnClass(Flyway.class)
+@ConditionalOnProperty(prefix = "plate.auth.flyway", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PlateAuthFlywayConfig {
@Bean(initMethod = "migrate")
diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java
new file mode 100644
index 0000000..cf7104c
--- /dev/null
+++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java
@@ -0,0 +1,80 @@
+package de.platesoft.auth.config;
+
+import de.platesoft.auth.PlateAuthProperties;
+import de.platesoft.auth.filter.JwtAuthenticationFilter;
+import de.platesoft.auth.service.JwtService;
+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.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.List;
+
+/**
+ * Security configuration for plate-auth. Registers a SecurityFilterChain
+ * that handles JWT validation and allows public access to auth endpoints.
+ */
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ public PasswordEncoder plateAuthPasswordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) {
+ return new JwtAuthenticationFilter(jwtService);
+ }
+
+ @Bean
+ @Order(-100)
+ public SecurityFilterChain plateAuthSecurityFilterChain(
+ HttpSecurity http,
+ JwtAuthenticationFilter jwtFilter,
+ PlateAuthProperties props) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(
+ "/api/auth/exchange",
+ "/api/auth/login",
+ "/api/auth/register",
+ "/api/auth/refresh",
+ "/api/auth/config",
+ "/actuator/health"
+ ).permitAll()
+ .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+
+ private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
+ CorsConfiguration config = new CorsConfiguration();
+ if (props.getCors().getAllowedOrigins().isEmpty()) {
+ config.setAllowedOriginPatterns(List.of("*"));
+ } else {
+ config.setAllowedOrigins(props.getCors().getAllowedOrigins());
+ config.setAllowCredentials(true);
+ }
+ config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+ config.setAllowedHeaders(List.of("*"));
+ config.setMaxAge(3600L);
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+ return source;
+ }
+}
diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java
index 820b7b7..bc3439f 100644
--- a/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java
+++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java
@@ -1,5 +1,6 @@
package de.platesoft.auth.service;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.dto.ExchangePayload;
@@ -30,10 +31,12 @@ import java.util.concurrent.ConcurrentMap;
@Service
public class ExchangeService {
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
private final String secret;
private final long maxAgeSeconds;
private final long nonceTtlSeconds;
- private final ObjectMapper mapper;
private final JwtService jwtService;
private final UserRepository userRepository;
private final UserIdentityRepository identityRepository;
@@ -44,7 +47,6 @@ public class ExchangeService {
public ExchangeService(
PlateAuthProperties props,
- ObjectMapper mapper,
JwtService jwtService,
UserRepository userRepository,
UserIdentityRepository identityRepository,
@@ -53,7 +55,6 @@ public class ExchangeService {
this.secret = props.getExchange().getSecret();
this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds();
this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds();
- this.mapper = mapper;
this.jwtService = jwtService;
this.userRepository = userRepository;
this.identityRepository = identityRepository;
@@ -78,7 +79,7 @@ public class ExchangeService {
ExchangePayload payload;
try {
- payload = mapper.readValue(body, ExchangePayload.class);
+ payload = MAPPER.readValue(body, ExchangePayload.class);
} catch (Exception e) {
throw new SecurityException("Malformed exchange payload");
}
diff --git a/pom.xml b/pom.xml
index 26bcd97..a64b8e8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -36,6 +36,7 @@
plate-auth-starter
+ it