feat: add OAuth2 Resource Server for Logto OIDC authentication

Dual auth: machine endpoints use Ed25519 JWT filter, all other API
endpoints use Spring Security OAuth2 Resource Server with Logto OIDC.
Mock JwtDecoder provided for test isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 15:03:06 +02:00
parent 9a575eaa94
commit 0d9c51843d
9 changed files with 74 additions and 9 deletions

View File

@@ -34,6 +34,12 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<!-- OAuth2 Resource Server (Logto OIDC) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JPA + PostgreSQL --> <!-- JPA + PostgreSQL -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter; import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
@@ -17,23 +18,39 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@EnableMethodSecurity @EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter machineTokenFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { public SecurityConfig(JwtAuthenticationFilter machineTokenFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.machineTokenFilter = machineTokenFilter;
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @Order(1)
public SecurityFilterChain machineAuthFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/agent/**", "/api/license/verify/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

View File

@@ -3,3 +3,8 @@ spring:
show-sql: false show-sql: false
flyway: flyway:
clean-disabled: false clean-disabled: false
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://test-issuer.example.com/oidc

View File

@@ -8,6 +8,12 @@ spring:
flyway: flyway:
enabled: true enabled: true
locations: classpath:db/migration locations: classpath:db/migration
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${LOGTO_ISSUER_URI:}
jwk-set-uri: ${LOGTO_JWK_SET_URI:}
management: management:
endpoints: endpoints:
@@ -23,3 +29,7 @@ cameleer:
expiration: 86400 # 24 hours in seconds expiration: 86400 # 24 hours in seconds
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:} private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:} public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
identity:
logto-endpoint: ${LOGTO_ENDPOINT:}
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}

View File

@@ -6,7 +6,7 @@ import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
@SpringBootTest @SpringBootTest
@Import(TestcontainersConfig.class) @Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test") @ActiveProfiles("test")
class CameleerSaasApplicationTest { class CameleerSaasApplicationTest {

View File

@@ -0,0 +1,24 @@
package net.siegeln.cameleer.saas;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import java.time.Instant;
import java.util.Map;
@TestConfiguration
public class TestSecurityConfig {
@Bean
public JwtDecoder jwtDecoder() {
return token -> Jwt.withTokenValue(token)
.header("alg", "RS256")
.claim("sub", "test-user")
.claim("iss", "https://test-issuer.example.com/oidc")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build();
}
}

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.auth;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig; import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest; import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -20,7 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Import(TestcontainersConfig.class) @Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test") @ActiveProfiles("test")
class AuthControllerTest { class AuthControllerTest {

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.license;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig; import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -19,7 +20,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Import(TestcontainersConfig.class) @Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test") @ActiveProfiles("test")
class LicenseControllerTest { class LicenseControllerTest {

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig; import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -19,7 +20,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Import(TestcontainersConfig.class) @Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test") @ActiveProfiles("test")
class TenantControllerTest { class TenantControllerTest {