Skip to content

Commit 31ac10a

Browse files
fix: replace bypass by siret by bypass by email domain. re-call proconnect with application/jwt
1 parent c8bce04 commit 31ac10a

File tree

7 files changed

+229
-116
lines changed

7 files changed

+229
-116
lines changed

.env.dev.defaults

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ MONITORENV_OIDC_ENABLED=false
2828
MONITORENV_OIDC_LOGIN_URL=/oauth2/authorization/proconnect
2929
MONITORENV_OIDC_SUCCESS_URL=http://localhost:3000
3030
MONITORENV_OIDC_ERROR_URL=http://localhost:3000/register
31-
MONITORENV_OIDC_AUTHORIZED_SIRETS=1234567890,0987654321
31+
MONITORENV_OIDC_BYPASS_DOMAINS_FILTER=true
32+
MONITORENV_OIDC_AUTHORIZED_EMAIL_DOMAINS=
3233
MONITORENV_OIDC_CLIENT_ID=monitorenv
3334
MONITORENV_OIDC_CLIENT_SECRET=PNKVjpo4DNCVMCVYUZiS3wepPJdtdFhH
3435
MONITORENV_OIDC_REDIRECT_URI=http://localhost:8880/login/oauth2/code/proconnect

.env.infra.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ MONITORENV_OIDC_ENABLED=
2626
MONITORENV_OIDC_LOGIN_URL=
2727
MONITORENV_OIDC_SUCCESS_URL=
2828
MONITORENV_OIDC_ERROR_URL=
29-
MONITORENV_OIDC_AUTHORIZED_SIRETS=
29+
MONITORENV_OIDC_BYPASS_DOMAINS_FILTER=
30+
MONITORENV_OIDC_AUTHORIZED_EMAIL_DOMAINS=
3031
MONITORENV_OIDC_CLIENT_ID=
3132
MONITORENV_OIDC_CLIENT_SECRET=
3233
MONITORENV_OIDC_REDIRECT_URI=

.env.test.defaults

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ MONITORENV_OIDC_ENABLED=false
2727
MONITORENV_OIDC_LOGIN_URL=/oauth2/authorization/proconnect
2828
MONITORENV_OIDC_SUCCESS_URL=http://localhost:3000
2929
MONITORENV_OIDC_ERROR_URL=http://localhost:3000/register
30-
MONITORENV_OIDC_AUTHORIZED_SIRETS=1234567890,0987654321
30+
MONITORENV_OIDC_BYPASS_DOMAINS_FILTER=true
31+
MONITORENV_OIDC_AUTHORIZED_EMAIL_DOMAINS=
3132
MONITORENV_OIDC_CLIENT_ID=monitorenv
3233
MONITORENV_OIDC_CLIENT_SECRET=PNKVjpo4DNCVMCVYUZiS3wepPJdtdFhH
3334
MONITORENV_OIDC_REDIRECT_URI=http://localhost:8880/login/oauth2/code/proconnect

backend/src/main/kotlin/fr/gouv/cacem/monitorenv/config/OIDCProperties.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import org.springframework.context.annotation.Configuration
77
@ConfigurationProperties(prefix = "monitorenv.oidc")
88
class OIDCProperties {
99
var enabled: Boolean? = false
10-
var bypassSiretFilter: String? = "false"
10+
var bypassEmailDomainsFilter: String? = "false"
1111
var clientId: String = ""
1212
var clientSecret: String = ""
1313
var redirectUri: String = ""
1414
var loginUrl: String = ""
1515
var successUrl: String = ""
1616
var errorUrl: String = ""
17-
var authorizedSirets: List<String> = listOf()
17+
var authorizedEmailDomains: List<String> = listOf()
1818
var issuerUri: String = ""
1919

2020
/**

backend/src/main/kotlin/fr/gouv/cacem/monitorenv/config/SecurityConfig.kt

Lines changed: 133 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
package fr.gouv.cacem.monitorenv.config
22

3+
import com.fasterxml.jackson.databind.ObjectMapper
34
import fr.gouv.cacem.monitorenv.infrastructure.api.endpoints.publicapi.SpaController.Companion.FRONTEND_APP_ROUTES
45
import jakarta.servlet.http.HttpServletRequest
56
import jakarta.servlet.http.HttpServletResponse
7+
import org.slf4j.Logger
68
import org.slf4j.LoggerFactory
79
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
810
import org.springframework.context.annotation.Bean
911
import org.springframework.context.annotation.Configuration
12+
import org.springframework.http.HttpEntity
13+
import org.springframework.http.HttpHeaders
14+
import org.springframework.http.HttpMethod
1015
import org.springframework.security.config.annotation.web.builders.HttpSecurity
1116
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
1217
import org.springframework.security.core.AuthenticationException
18+
import org.springframework.security.core.GrantedAuthority
1319
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
1420
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
1521
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler
1622
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
1723
import 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
1827
import org.springframework.security.oauth2.core.oidc.user.OidcUser
1928
import org.springframework.security.web.SecurityFilterChain
2029
import org.springframework.security.web.authentication.AuthenticationFailureHandler
@@ -23,52 +32,138 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationFa
2332
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
2433
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler
2534
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
35+
import org.springframework.web.client.RestTemplate
2636
import org.springframework.web.cors.CorsConfiguration
2737
import org.springframework.web.cors.CorsConfigurationSource
2838
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
29-
import kotlin.String
30-
import kotlin.apply
39+
import java.util.*
3140

3241
@Configuration
3342
@EnableWebSecurity
3443
class 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

Comments
 (0)