11package fr.gouv.cacem.monitorenv.config
22
3+ import com.fasterxml.jackson.databind.ObjectMapper
34import fr.gouv.cacem.monitorenv.infrastructure.api.endpoints.publicapi.SpaController.Companion.FRONTEND_APP_ROUTES
45import jakarta.servlet.http.HttpServletRequest
56import jakarta.servlet.http.HttpServletResponse
7+ import org.slf4j.Logger
68import org.slf4j.LoggerFactory
79import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
810import org.springframework.context.annotation.Bean
911import org.springframework.context.annotation.Configuration
12+ import org.springframework.http.HttpEntity
13+ import org.springframework.http.HttpHeaders
14+ import org.springframework.http.HttpMethod
1015import org.springframework.security.config.annotation.web.builders.HttpSecurity
1116import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
1217import org.springframework.security.core.AuthenticationException
18+ import org.springframework.security.core.GrantedAuthority
1319import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
1420import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
1521import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler
1622import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
1723import org.springframework.security.oauth2.core.OAuth2AuthenticationException
24+ import org.springframework.security.oauth2.core.OAuth2Error
25+ import org.springframework.security.oauth2.core.oidc.OidcUserInfo
26+ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
1827import org.springframework.security.oauth2.core.oidc.user.OidcUser
1928import org.springframework.security.web.SecurityFilterChain
2029import org.springframework.security.web.authentication.AuthenticationFailureHandler
@@ -23,52 +32,138 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationFa
2332import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
2433import org.springframework.security.web.authentication.logout.LogoutSuccessHandler
2534import org.springframework.security.web.util.matcher.AntPathRequestMatcher
35+ import org.springframework.web.client.RestTemplate
2636import org.springframework.web.cors.CorsConfiguration
2737import org.springframework.web.cors.CorsConfigurationSource
2838import org.springframework.web.cors.UrlBasedCorsConfigurationSource
29- import kotlin.String
30- import kotlin.apply
39+ import java.util.*
3140
3241@Configuration
3342@EnableWebSecurity
3443class SecurityConfig (
3544 val oidcProperties : OIDCProperties ,
3645 val clientRegistrationRepository : ClientRegistrationRepository ? ,
3746) {
38- private val logger = LoggerFactory .getLogger(SecurityConfig ::class .java)
47+ private val logger: Logger = LoggerFactory .getLogger(SecurityConfig ::class .java)
3948
4049 @Bean
4150 @ConditionalOnProperty(value = [" monitorenv.oidc.enabled" ], havingValue = " true" )
4251 fun customOidcUserService (): OidcUserService {
4352 return object : OidcUserService () {
44- override fun loadUser (userRequest : OidcUserRequest ): OidcUser {
53+ private val restTemplate = RestTemplate ()
54+ private val objectMapper = ObjectMapper ()
55+
56+ override fun loadUser (userRequest : OidcUserRequest ): OidcUser =
4557 try {
4658 val oidcUser = super .loadUser(userRequest)
47- if (oidcProperties.bypassSiretFilter == " true" ) {
48- logger.info(" OIDC is with Cerbère, bypassing the SIRET checks." )
4959
50- return oidcUser
51- }
60+ validateAndProcessUser(oidcUser)
61+ } catch (e: Exception ) {
62+ // ProConnect returns userinfo as JWT instead of JSON
63+ if (e.message?.contains(" application/jwt" ) == true ) {
64+ logger.info(" UserInfo endpoint returned JWT content type, decoding JWT response..." )
5265
53- val siretsClaimRaw = oidcUser.claims[" SIRET" ]
66+ try {
67+ val oidcUser = loadUserFromJwtUserInfo(userRequest)
5468
55- val tokenSirets: Set <String > =
56- when (siretsClaimRaw) {
57- is List <* > -> siretsClaimRaw.filterIsInstance<String >().toSet()
58- is String -> setOf (siretsClaimRaw)
59- else -> throw OAuth2AuthenticationException (" SIRET claim missing or malformed" )
69+ validateAndProcessUser(oidcUser)
70+ } catch (jwtError: Exception ) {
71+ logger.error(" ⛔ Failed to decode JWT userinfo response" , jwtError)
72+
73+ throw OAuth2AuthenticationException (
74+ OAuth2Error (
75+ " invalid_user_info_response" ,
76+ " Failed to decode JWT userinfo response: ${jwtError.message} " ,
77+ null ,
78+ ),
79+ jwtError as Throwable ,
80+ )
6081 }
82+ } else {
83+ logger.error(" ⛔ Exception in loadUser" , e)
84+ throw e
85+ }
86+ }
87+
88+ private fun loadUserFromJwtUserInfo (userRequest : OidcUserRequest ): OidcUser {
89+ val userInfoUri = userRequest.clientRegistration.providerDetails.userInfoEndpoint.uri
90+ val accessToken = userRequest.accessToken.tokenValue
91+
92+ logger.debug(" Fetching JWT userinfo from: $userInfoUri " )
6193
62- val isAuthorized = oidcProperties.authorizedSirets.any { it in tokenSirets }
63- if ( ! isAuthorized) {
64- throw OAuth2AuthenticationException ( " User not authorized for the requested SIRET(s) " )
94+ val headers =
95+ HttpHeaders (). apply {
96+ setBearerAuth(accessToken )
6597 }
6698
99+ val response =
100+ restTemplate.exchange(
101+ userInfoUri,
102+ HttpMethod .GET ,
103+ HttpEntity <String >(headers),
104+ String ::class .java,
105+ )
106+
107+ val jwtToken = response.body ? : throw IllegalArgumentException (" Empty userinfo response" )
108+
109+ val claims = decodeJwtClaims(jwtToken)
110+ logger.debug(" Successfully decoded JWT userinfo with claims: {}" , claims.keys)
111+
112+ // Create OidcUserInfo and OidcUser with the decoded claims
113+ val userInfo = OidcUserInfo (claims)
114+ return DefaultOidcUser (
115+ emptyList<GrantedAuthority >(),
116+ userRequest.idToken,
117+ userInfo,
118+ )
119+ }
120+
121+ private fun decodeJwtClaims (jwtToken : String ): Map <String , Any > {
122+ val parts = jwtToken.split(" ." )
123+ if (parts.size != 3 ) {
124+ throw IllegalArgumentException (" Invalid JWT format: expected 3 parts, got ${parts.size} " )
125+ }
126+
127+ val payloadBase64 = parts[1 ]
128+ val decodedBytes = Base64 .getUrlDecoder().decode(payloadBase64)
129+ val payloadJson = String (decodedBytes, Charsets .UTF_8 )
130+
131+ @Suppress(" UNCHECKED_CAST" )
132+ return objectMapper.readValue(payloadJson, LinkedHashMap ::class .java) as Map <String , Any >
133+ }
134+
135+ private fun validateAndProcessUser (oidcUser : OidcUser ): OidcUser {
136+
137+ if (oidcProperties.bypassEmailDomainsFilter == " true" ) {
138+ logger.info(" ✅ OIDC is bypassing email domain checks." )
67139 return oidcUser
68- } catch (e: Exception ) {
69- logger.error(" ⛔ Exception in loadUser" , e)
70- throw e
71140 }
141+
142+ val emailClaim = oidcUser.claims[" email" ] as ? String
143+ logger.debug(" User email from JWT: $emailClaim " )
144+
145+ if (emailClaim.isNullOrBlank()) {
146+ val errorMsg = " Email claim is missing or empty in JWT"
147+ logger.error(" ❌ $errorMsg " )
148+ throw OAuth2AuthenticationException (errorMsg)
149+ }
150+
151+ val emailDomain = emailClaim.substringAfterLast(" @" )
152+
153+ val isAuthorized =
154+ oidcProperties.authorizedEmailDomains.any { domain ->
155+ emailDomain.equals(domain, ignoreCase = true )
156+ }
157+
158+ if (! isAuthorized) {
159+ val errorMsg =
160+ " User not authorized. Email domain '$emailDomain ' does not match any authorized domain: ${oidcProperties.authorizedEmailDomains} "
161+ logger.error(" ❌ $errorMsg " )
162+ throw OAuth2AuthenticationException (errorMsg)
163+ }
164+
165+ logger.info(" ✅ User authorized with email domain: $emailDomain " )
166+ return oidcUser
72167 }
73168 }
74169 }
@@ -100,14 +195,13 @@ class SecurityConfig(
100195
101196 authorize.requestMatchers(" /**" ).permitAll()
102197 } else {
103- logger.info (
198+ logger.warn (
104199 """
105200 ✅ OIDC Authentication is enabled.
106201 """ .trimIndent(),
107202 )
108203
109204 authorize
110- // Autorise tout le monde sur ces routes (ex: statiques, version, health)
111205 .requestMatchers(
112206 " /" ,
113207 * FRONTEND_APP_ROUTES .toTypedArray(),
@@ -139,6 +233,7 @@ class SecurityConfig(
139233 .authenticated()
140234 }
141235 }
236+
142237 if (oidcProperties.enabled == true && clientRegistrationRepository != null ) {
143238 http
144239 .oauth2Login { oauth2 ->
@@ -164,7 +259,7 @@ class SecurityConfig(
164259 @Bean
165260 @ConditionalOnProperty(value = [" monitorenv.oidc.enabled" ], havingValue = " true" )
166261 fun successHandler (): AuthenticationSuccessHandler {
167- println (" Redirect URL is: '${oidcProperties.successUrl} '" )
262+ logger.info (" Redirect URL is: '${oidcProperties.successUrl} '" )
168263 return SimpleUrlAuthenticationSuccessHandler (oidcProperties.successUrl)
169264 }
170265
@@ -177,7 +272,19 @@ class SecurityConfig(
177272 response : HttpServletResponse ,
178273 exception : AuthenticationException ,
179274 ) {
180- logger.error(" Authentication failed: ${exception.message} " , exception)
275+ val errorMessage =
276+ exception.message
277+ ? : exception.cause?.message
278+ ? : exception.javaClass.simpleName
279+
280+ logger.error(" ❌ Authentication failed: $errorMessage " , exception)
281+
282+ // Log the full exception chain for debugging
283+ if (exception.cause != null ) {
284+ logger.error(
285+ " Caused by: ${exception.cause?.javaClass?.simpleName} - ${exception.cause?.message} " ,
286+ )
287+ }
181288
182289 super .onAuthenticationFailure(request, response, exception)
183290 }
@@ -192,8 +299,10 @@ class SecurityConfig(
192299 allowedHeaders = listOf (" Authorization" , " Cache-Control" , " Content-Type" )
193300 allowCredentials = true
194301 }
302+
195303 val source = UrlBasedCorsConfigurationSource ()
196304 source.registerCorsConfiguration(" /**" , configuration)
305+
197306 return source
198307 }
199308}
0 commit comments