Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ java {

repositories {
mavenCentral()
// Required for OpenSAML transitive dependencies of spring-security-saml2-service-provider
// maven { url = uri("https://build.shibboleth.net/nexus/content/repositories/releases/") }
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
// SAML2 SSO: Add the following dependency and the Shibboleth Maven repository
// (https://build.shibboleth.net/nexus/content/repositories/releases/) to enable
// SAML-based authentication. The SamlSecurityConfig bean is @ConditionalOnProperty
// and only activates when spring.security.saml2.relyingparty.registration.* is configured.
// implementation("org.springframework.security:spring-security-saml2-service-provider")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.opendatamask.application.service.AuthService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.*

@RestController
Expand All @@ -24,5 +25,23 @@ class AuthController(
fun register(@Valid @RequestBody request: RegisterRequest): ResponseEntity<AuthResponse> {
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request))
}

// Returns the currently authenticated user's name.
// Works for both JWT (principal is a UserDetails) and SAML (principal is a
// Saml2AuthenticatedPrincipal) since both implement Authentication.getName().
// The /api/auth/** path is permitAll() so unauthenticated requests reach this endpoint;
// in that case authentication is null or not authenticated and we return 401 explicitly.
@GetMapping("/me")
fun me(authentication: Authentication?): ResponseEntity<Map<String, String>> {
if (authentication == null || !authentication.isAuthenticated) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
}
Comment on lines +34 to +38
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AuthenticationPrincipal principal: UserDetails? will only be populated for authentication types whose principal implements UserDetails (e.g., your JWT flow). For SAML2, the principal is typically a Saml2AuthenticatedPrincipal, so this parameter will be null and the endpoint will incorrectly return 401 even when the user is authenticated via SAML. Use a more general type (e.g., Principal, Authentication, or AuthenticatedPrincipal) and extract the name/attributes accordingly.

Copilot uses AI. Check for mistakes.
return ResponseEntity.ok(
mapOf(
"username" to authentication.name,
"authenticated" to "true"
)
)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.opendatamask.infrastructure.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource

@Configuration
class CorsConfig(
@Value("\${opendatamask.cors.allowed-origins:http://localhost:5173}")
private val allowedOrigins: List<String>
) {

@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration()
config.allowedOrigins = allowedOrigins
config.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
config.allowedHeaders = listOf("*")
config.exposedHeaders = listOf("X-XSRF-TOKEN")
config.allowCredentials = true
config.maxAge = 3600L

val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", config)
return source
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.opendatamask.infrastructure.config

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
import org.springframework.core.annotation.Order
import org.springframework.security.config.Customizer
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.security.web.util.matcher.AntPathRequestMatcher

// SAML2 Single Sign-On security filter chain.
//
// This configuration is activated only when BOTH conditions are true:
// 1. The `spring-security-saml2-service-provider` library is on the classpath, AND
// 2. The env var SAML_IDP_METADATA_URI is set to a non-empty value, which binds
// `spring.security.saml2.relyingparty.registration.default.assertingparty.metadata-uri`.
// The property is NOT defined in application.yml with an empty default, so it is
// absent from the Spring Environment unless the env var is explicitly provided.
// This ensures `@ConditionalOnProperty` does not activate on empty/blank values.
//
// To enable SAML SSO:
// 1. Add the Shibboleth repository and the SAML SP dependency to build.gradle.kts:
// repositories {
// maven { url = uri("https://build.shibboleth.net/nexus/content/repositories/releases/") }
// }
// dependencies {
// implementation("org.springframework.security:spring-security-saml2-service-provider")
// }
// 2. Set the required environment variables (see application.yml comments):
// SAML_IDP_METADATA_URI=https://idp.example.com/metadata
// SAML_SP_ENTITY_ID=https://your-app.example.com
// SAML_SP_PRIVATE_KEY=classpath:saml/sp-private-key.pem
// SAML_SP_CERTIFICATE=classpath:saml/sp-certificate.pem
// SAML_IDP_SSO_URL=https://idp.example.com/sso
@Configuration
@ConditionalOnClass(name = ["org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository"])
@ConditionalOnProperty(
name = ["spring.security.saml2.relyingparty.registration.default.assertingparty.metadata-uri"],
matchIfMissing = false
)
class SamlSecurityConfig(
Comment on lines +38 to +44
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ConditionalOnProperty here only checks for property presence, not that it’s non-empty. Since application.yml defines spring.security.saml2...metadata-uri with a default empty value (${SAML_IDP_METADATA_URI:}), this condition will evaluate true whenever the SAML classes are on the classpath—even when SAML isn’t configured. That undermines the “zero-impact when unconfigured” goal and may fail startup once the dependency is enabled. Consider conditioning on an explicit boolean flag, or use a condition that checks the metadata-uri is not blank, and/or avoid defining the property with an empty default in the baseline config.

Copilot uses AI. Check for mistakes.
private val corsConfig: CorsConfig
) {

// SAML2 web security filter chain (order=2): intercepts browser routes before the
// fallback webSecurityFilterChain (order=3) in SecurityConfig.
// CSRF protection is enabled via CookieCsrfTokenRepository so the SPA can read the
// XSRF-TOKEN cookie and send it back in an X-XSRF-TOKEN header on mutating requests.
@Bean
@Order(2)
fun samlSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.cors { it.configurationSource(corsConfig.corsConfigurationSource()) }
.csrf { it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers(
AntPathRequestMatcher("/saml2/**"),
AntPathRequestMatcher("/login/**"),
AntPathRequestMatcher("/error")
).permitAll()
.anyRequest().authenticated()
}
.saml2Login(Customizer.withDefaults())
.saml2Logout(Customizer.withDefaults())

return http.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.opendatamask.infrastructure.config
import com.opendatamask.infrastructure.security.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
Expand All @@ -14,14 +16,18 @@ import org.springframework.security.core.userdetails.UserDetailsService
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.HttpStatusEntryPoint
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.security.web.util.matcher.AntPathRequestMatcher

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val userDetailsService: UserDetailsService
private val userDetailsService: UserDetailsService,
private val corsConfig: CorsConfig
) {

@Bean
Expand All @@ -39,21 +45,60 @@ class SecurityConfig(
fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager =
config.authenticationManager

// API security filter chain (highest priority, order=1): handles /api/** endpoints.
// Uses JWT Bearer tokens. Returns HTTP 401 for unauthenticated requests instead of
// redirecting to an IdP. CSRF is disabled since JWT-only requests use Bearer tokens.
// Session policy is NEVER: does not create new sessions but will read an existing one
// (e.g. a SAML session initiated by the browser-based flow), allowing SAML-authenticated
// users to call API endpoints using their session cookie.
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
@Order(1)
fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.securityMatcher("/api/**")
.cors { it.configurationSource(corsConfig.corsConfigurationSource()) }
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.NEVER) }
.authorizeHttpRequests { auth ->
Comment on lines +58 to 62
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/api/** is configured as STATELESS with CSRF disabled, but the frontend now uses withCredentials + CSRF headers and AuthController.me is intended to detect a SAML session. With SessionCreationPolicy.STATELESS, Spring Security will not load the SecurityContext from the HTTP session, so SAML-authenticated browser sessions won’t be recognized on /api/** (likely causing /api/auth/me and other API calls to return 401 and potentially creating redirect loops). Consider using a session policy that can read an existing session (e.g., IF_REQUIRED) and enabling CSRF for cookie-based auth while still ignoring CSRF for Bearer-token requests.

Copilot uses AI. Check for mistakes.
auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
}
.exceptionHandling { exceptions ->
exceptions.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
}
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)

return http.build()
}

// Fallback web security filter chain (lowest priority, order=3): handles all non-API routes.
// Protects against CSRF using a cookie-based token repository so the SPA can read the
// XSRF-TOKEN cookie and send it back via X-XSRF-TOKEN on mutating requests.
// When SamlSecurityConfig is active (order=2) it intercepts browser routes before this chain.
// The /saml2/**, /login/**, /error matchers here are the fallback for when SAML is NOT
// active; they mirror the SamlSecurityConfig matchers intentionally so those paths are
// always accessible regardless of whether the SAML SP library is on the classpath.
@Bean
@Order(3)
fun webSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.cors { it.configurationSource(corsConfig.corsConfigurationSource()) }
.csrf { it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(
AntPathRequestMatcher("/saml2/**"),
AntPathRequestMatcher("/login/**"),
AntPathRequestMatcher("/error")
).permitAll()
.anyRequest().authenticated()
}
.formLogin { it.disable() }

return http.build()
}
}

2 changes: 2 additions & 0 deletions backend/src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ spring:
opendatamask:
encryption:
key: 0123456789abcdef
cors:
allowed-origins: http://localhost:5173
12 changes: 12 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ spring:
security:
user:
name: admin
# SAML2 SSO is configured via environment variables at deployment time.
# Set the variables below (and uncomment spring-security-saml2-service-provider
# in build.gradle.kts) to activate the SamlSecurityConfig filter chain.
# Required env vars:
# SAML_IDP_METADATA_URI – IdP metadata URL (activates the SAML chain when set)
# SAML_SP_ENTITY_ID – SP entity ID (default: https://opendatamask.example.com)
# SAML_SP_PRIVATE_KEY – path to SP signing key PEM
# SAML_SP_CERTIFICATE – path to SP certificate PEM
# SAML_IDP_SSO_URL – IdP SSO redirect URL
data:
mongodb:
uri: ${MONGODB_URI:mongodb://localhost:27017}
Expand All @@ -33,6 +42,8 @@ opendatamask:
expiration: ${JWT_EXPIRATION:86400000}
encryption:
key: ${ENCRYPTION_KEY:}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173}

management:
endpoints:
Expand All @@ -42,3 +53,4 @@ management:
endpoint:
health:
show-details: never

Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,46 @@ class AuthControllerTest {
}

@Test
fun `POST api-auth-register returns 400 for duplicate username`() {
val username = "dupctrl_${System.nanoTime()}"
fun `GET api-auth-me returns 200 with username for authenticated user`() {
val username = "mectrl_${System.nanoTime()}"
val email = "mectrl_${System.nanoTime()}@example.com"
val password = "password123"

val request1 = RegisterRequest(username = username, email = "first_${System.nanoTime()}@example.com", password = "password123")
val registerRequest = RegisterRequest(username = username, email = email, password = password)
mockMvc.post("/api/auth/register") {
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(request1)
content = objectMapper.writeValueAsString(registerRequest)
}.andExpect { status { isCreated() } }

val request2 = RegisterRequest(username = username, email = "second_${System.nanoTime()}@example.com", password = "password456")
mockMvc.post("/api/auth/register") {
val loginResult = mockMvc.post("/api/auth/login") {
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(request2)
content = objectMapper.writeValueAsString(LoginRequest(username = username, password = password))
}.andExpect {
status { isBadRequest() }
}
status { isOk() }
jsonPath("$.token") { exists() }
}.andReturn()

val token = objectMapper.readTree(loginResult.response.contentAsString)["token"].asText()

mockMvc.perform(
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/auth/me")
.header("Authorization", "Bearer $token")
).andExpect(
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk
).andExpect(
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.username").value(username)
).andExpect(
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.authenticated").value("true")
)
}

@Test
fun `GET api-auth-me returns 401 for unauthenticated request`() {
mockMvc.perform(
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/auth/me")
).andExpect(
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized
)
}
}

Loading
Loading