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>
</dependency>
<!-- OAuth2 Resource Server (Logto OIDC) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JPA + PostgreSQL -->
<dependency>
<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 org.springframework.context.annotation.Bean;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -17,23 +18,39 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationFilter machineTokenFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter machineTokenFilter) {
this.machineTokenFilter = machineTokenFilter;
}
@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
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

View File

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

View File

@@ -8,6 +8,12 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${LOGTO_ISSUER_URI:}
jwk-set-uri: ${LOGTO_JWK_SET_URI:}
management:
endpoints:
@@ -23,3 +29,7 @@ cameleer:
expiration: 86400 # 24 hours in seconds
private-key-path: ${CAMELEER_JWT_PRIVATE_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;
@SpringBootTest
@Import(TestcontainersConfig.class)
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
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 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.RegisterRequest;
import org.junit.jupiter.api.Test;
@@ -20,7 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class AuthControllerTest {

View File

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

View File

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