This is an automated email from the ASF dual-hosted git repository. ilgrosso pushed a commit to branch 4_0_X in repository https://gitbox.apache.org/repos/asf/syncope.git
commit f8a80c6d7e6fddc27fc389d2699fcd91b41a6663 Author: Francesco Chicchiriccò <[email protected]> AuthorDate: Wed Sep 9 14:22:52 2026 +0200 SRA: require JWKS URI for OAuth 2.0 --- .../markup/html/bootstrap/dialog/BaseModal.java | 2 +- .../apache/syncope/fit/sra/AbstractOIDCITCase.java | 87 +-------------------- .../apache/syncope/fit/sra/OAUTH2SRAITCase.java | 5 -- .../org/apache/syncope/fit/sra/OIDCSRAITCase.java | 91 +++++++++++++++++++++- .../src/test/resources/sra-oauth2.properties | 2 +- .../org/apache/syncope/sra/SecurityConfig.java | 17 ++-- 6 files changed, 96 insertions(+), 108 deletions(-) diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/bootstrap/dialog/BaseModal.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/bootstrap/dialog/BaseModal.java index 0fe0aeeb29..d5abfa7ece 100644 --- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/bootstrap/dialog/BaseModal.java +++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/bootstrap/dialog/BaseModal.java @@ -108,7 +108,7 @@ public class BaseModal<T extends Serializable> extends Modal<T> { defaultModalCloseButton = new DefaultModalCloseButton(); addButton(defaultModalCloseButton); - setUseKeyboard(true); + setCloseOnEscapeKey(true); setFadeIn(true); setBackdrop(Modal.Backdrop.STATIC); } diff --git a/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/AbstractOIDCITCase.java b/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/AbstractOIDCITCase.java index 0e46ea018f..2812770923 100644 --- a/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/AbstractOIDCITCase.java +++ b/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/AbstractOIDCITCase.java @@ -19,28 +19,19 @@ package org.apache.syncope.fit.sra; import static org.awaitility.Awaitility.await; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.oneOf; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.io.IOException; -import java.text.ParseException; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.concurrent.TimeUnit; import org.apache.cxf.jaxrs.client.WebClient; import org.apache.http.Consts; @@ -89,7 +80,7 @@ abstract class AbstractOIDCITCase extends AbstractSRAITCase { return POLICY_SERVICE.list(PolicyType.ATTR_RELEASE).stream(). map(AttrReleasePolicyTO.class::cast). filter(policy -> description.equals(policy.getName()) - && policy.getConf() instanceof DefaultAttrReleasePolicyConf). + && policy.getConf() instanceof DefaultAttrReleasePolicyConf). findFirst(). orElseGet(() -> { DefaultAttrReleasePolicyConf policyConf = new DefaultAttrReleasePolicyConf(); @@ -263,80 +254,4 @@ abstract class AbstractOIDCITCase extends AbstractSRAITCase { response = httpclient.execute(get, context); checkLogout(response); } - - @SuppressWarnings("unchecked") - private void checkJWT(final String token, final boolean idToken) throws ParseException { - assertNotNull(token); - SignedJWT jwt = SignedJWT.parse(token); - assertNotNull(jwt); - JWTClaimsSet idTokenClaimsSet = jwt.getJWTClaimsSet(); - assertEquals("verdi", idTokenClaimsSet.getSubject()); - if (idToken) { - assertEquals("verdi", idTokenClaimsSet.getStringClaim("preferred_username")); - } - assertEquals("[email protected]", idTokenClaimsSet.getStringClaim("email")); - assertEquals("Verdi", idTokenClaimsSet.getStringClaim("family_name")); - assertEquals("Giuseppe", idTokenClaimsSet.getStringClaim("given_name")); - assertEquals("Giuseppe Verdi", idTokenClaimsSet.getStringClaim("name")); - List<Object> groups = idTokenClaimsSet.getListClaim("groups"); - assertEquals(3, groups.size()); - groups.stream().anyMatch(g -> ((Map<String, String>) g).equals(Map.of("groupName", "root"))); - groups.stream().anyMatch(g -> ((Map<String, String>) g).equals(Map.of("groupName", "child"))); - groups.stream().anyMatch(g -> ((Map<String, String>) g).equals(Map.of("groupName", "citizen"))); - } - - protected abstract boolean checkIdToken(); - - @Test - void rest() throws IOException, ParseException { - // 0. access public route - WebClient client = WebClient.create(SRA_ADDRESS + "/public/post"). - accept(MediaType.APPLICATION_JSON).type(MediaType.APPLICATION_JSON); - Response response = client.post(null); - assertEquals(HttpStatus.SC_OK, response.getStatus()); - - // 1. obtain id and access tokens - Form form = new Form(). - param("grant_type", "password"). - param("client_id", CLIENT_ID). - param("client_secret", CLIENT_SECRET). - param("username", "verdi"). - param("password", "password"). - param("scope", "openid profile email syncope"); - response = WebClient.create(TOKEN_URI).post(form); - assertEquals(HttpStatus.SC_OK, response.getStatus()); - assertTrue(response.getHeaderString(HttpHeaders.CONTENT_TYPE).startsWith(MediaType.APPLICATION_JSON)); - - JsonNode json = MAPPER.readTree(response.readEntity(String.class)); - - if (checkIdToken()) { - // 1a. take and verify id_token - String idToken = json.get("id_token").asText(); - assertNotNull(idToken); - checkJWT(idToken, true); - } - - // 1b. take and verify access_token - String accessToken = json.get("access_token").asText(); - checkJWT(accessToken, false); - - // 2. access protected route - client = WebClient.create(SRA_ADDRESS + "/protected/post"). - authorization("Bearer " + accessToken). - accept(MediaType.APPLICATION_JSON).type(MediaType.APPLICATION_JSON); - response = client.post(null); - - assertEquals(HttpStatus.SC_OK, response.getStatus()); - - json = MAPPER.readTree(response.readEntity(String.class)); - - ObjectNode headers = (ObjectNode) json.get("headers"); - assertEquals(MediaType.APPLICATION_JSON, headers.get(HttpHeaders.ACCEPT).asText()); - assertEquals(MediaType.APPLICATION_JSON, headers.get(HttpHeaders.CONTENT_TYPE).asText()); - assertThat(headers.get("X-Forwarded-Host").asText(), is(oneOf("localhost:8080", "127.0.0.1:8080"))); - - String withHost = client.getBaseURI().toASCIIString().replace("/protected", ""); - String withIP = withHost.replace("localhost", "127.0.0.1"); - assertThat(json.get("url").asText(), is(oneOf(withHost, withIP))); - } } diff --git a/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OAUTH2SRAITCase.java b/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OAUTH2SRAITCase.java index 1d89ac56d5..c3c7257931 100644 --- a/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OAUTH2SRAITCase.java +++ b/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OAUTH2SRAITCase.java @@ -68,9 +68,4 @@ class OAUTH2SRAITCase extends AbstractOIDCITCase { protected void checkLogout(final CloseableHttpResponse response) { assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatusLine().getStatusCode()); } - - @Override - protected boolean checkIdToken() { - return false; - } } diff --git a/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OIDCSRAITCase.java b/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OIDCSRAITCase.java index 16bf410049..b433982a6a 100644 --- a/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OIDCSRAITCase.java +++ b/fit/wa-reference/src/test/java/org/apache/syncope/fit/sra/OIDCSRAITCase.java @@ -18,16 +18,34 @@ */ package org.apache.syncope.fit.sra; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.oneOf; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; +import java.util.List; +import java.util.Map; import java.util.Properties; import java.util.concurrent.TimeoutException; +import org.apache.cxf.jaxrs.client.WebClient; +import org.apache.http.HttpStatus; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; class OIDCSRAITCase extends AbstractOIDCITCase { @@ -60,8 +78,75 @@ class OIDCSRAITCase extends AbstractOIDCITCase { OIDCSRAITCase.class.getName(), SRA_REGISTRATION_ID, CLIENT_APP_ID, CLIENT_ID, CLIENT_SECRET); } - @Override - protected boolean checkIdToken() { - return true; + @SuppressWarnings("unchecked") + private void checkJWT(final String token, final boolean idToken) throws Exception { + assertNotNull(token); + SignedJWT jwt = SignedJWT.parse(token); + assertNotNull(jwt); + JWTClaimsSet idTokenClaimsSet = jwt.getJWTClaimsSet(); + assertEquals("verdi", idTokenClaimsSet.getSubject()); + if (idToken) { + assertEquals("verdi", idTokenClaimsSet.getStringClaim("preferred_username")); + } + assertEquals("[email protected]", idTokenClaimsSet.getStringClaim("email")); + assertEquals("Verdi", idTokenClaimsSet.getStringClaim("family_name")); + assertEquals("Giuseppe", idTokenClaimsSet.getStringClaim("given_name")); + assertEquals("Giuseppe Verdi", idTokenClaimsSet.getStringClaim("name")); + List<Object> groups = idTokenClaimsSet.getListClaim("groups"); + assertEquals(3, groups.size()); + groups.stream().anyMatch(g -> ((Map<String, String>) g).equals(Map.of("groupName", "root"))); + groups.stream().anyMatch(g -> ((Map<String, String>) g).equals(Map.of("groupName", "child"))); + groups.stream().anyMatch(g -> ((Map<String, String>) g).equals(Map.of("groupName", "citizen"))); + } + + @Test + void rest() throws Exception { + // 0. access public route + WebClient client = WebClient.create(SRA_ADDRESS + "/public/post"). + accept(MediaType.APPLICATION_JSON).type(MediaType.APPLICATION_JSON); + Response response = client.post(null); + assertEquals(HttpStatus.SC_OK, response.getStatus()); + + // 1. obtain id and access tokens + Form form = new Form(). + param("grant_type", "password"). + param("client_id", CLIENT_ID). + param("client_secret", CLIENT_SECRET). + param("username", "verdi"). + param("password", "password"). + param("scope", "openid profile email syncope"); + response = WebClient.create(TOKEN_URI).post(form); + assertEquals(HttpStatus.SC_OK, response.getStatus()); + assertTrue(response.getHeaderString(HttpHeaders.CONTENT_TYPE).startsWith(MediaType.APPLICATION_JSON)); + + JsonNode json = MAPPER.readTree(response.readEntity(String.class)); + + // 1a. take and verify id_token + String idToken = json.get("id_token").asText(); + assertNotNull(idToken); + checkJWT(idToken, true); + + // 1b. take and verify access_token + String accessToken = json.get("access_token").asText(); + checkJWT(accessToken, false); + + // 2. access protected route + client = WebClient.create(SRA_ADDRESS + "/protected/post"). + authorization("Bearer " + accessToken). + accept(MediaType.APPLICATION_JSON).type(MediaType.APPLICATION_JSON); + response = client.post(null); + + assertEquals(HttpStatus.SC_OK, response.getStatus()); + + json = MAPPER.readTree(response.readEntity(String.class)); + + ObjectNode headers = (ObjectNode) json.get("headers"); + assertEquals(MediaType.APPLICATION_JSON, headers.get(HttpHeaders.ACCEPT).asText()); + assertEquals(MediaType.APPLICATION_JSON, headers.get(HttpHeaders.CONTENT_TYPE).asText()); + assertThat(headers.get("X-Forwarded-Host").asText(), is(oneOf("localhost:8080", "127.0.0.1:8080"))); + + String withHost = client.getBaseURI().toASCIIString().replace("/protected", ""); + String withIP = withHost.replace("localhost", "127.0.0.1"); + assertThat(json.get("url").asText(), is(oneOf(withHost, withIP))); } } diff --git a/fit/wa-reference/src/test/resources/sra-oauth2.properties b/fit/wa-reference/src/test/resources/sra-oauth2.properties index bc527e2210..dc72ba2a5e 100644 --- a/fit/wa-reference/src/test/resources/sra-oauth2.properties +++ b/fit/wa-reference/src/test/resources/sra-oauth2.properties @@ -28,7 +28,7 @@ sra.oauth2.authorizationUri=https://localhost:9443/syncope-wa/oauth2.0/authorize sra.oauth2.userInfoUri=https://localhost:9443/syncope-wa/oauth2.0/profile sra.oauth2.userNameAttributeName=id sra.oauth2.scopes= -sra.oauth2.jwkSetUri= +sra.oauth2.jwkSetUri=https://localhost:9443/syncope-wa/oidc/jwks sra.oauth2.issuer=https://localhost:9443/syncope-wa sra.oauth2.client-id=oauth2TestClientId sra.oauth2.client-secret=oauth2TestClientSecret diff --git a/sra/src/main/java/org/apache/syncope/sra/SecurityConfig.java b/sra/src/main/java/org/apache/syncope/sra/SecurityConfig.java index b8ade85df3..53ca7e8f2e 100644 --- a/sra/src/main/java/org/apache/syncope/sra/SecurityConfig.java +++ b/sra/src/main/java/org/apache/syncope/sra/SecurityConfig.java @@ -23,7 +23,6 @@ import java.security.KeyStore; import java.security.PrivateKey; import java.security.Security; import java.security.cert.X509Certificate; -import java.text.ParseException; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.syncope.common.lib.types.IdRepoEntitlement; @@ -78,7 +77,6 @@ import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; -import reactor.core.publisher.Mono; @EnableWebFluxSecurity @Configuration(proxyBeanMethods = false) @@ -228,18 +226,13 @@ public class SecurityConfig { final Converter<Map<String, Object>, Map<String, Object>> jwtClaimSetConverter) { String jwkSetUri = oauth2ClientRegistration.getProviderDetails().getJwkSetUri(); - NimbusReactiveJwtDecoder jwtDecoder; if (StringUtils.isBlank(jwkSetUri)) { - jwtDecoder = new NimbusReactiveJwtDecoder(jwt -> { - try { - return Mono.just(jwt.getJWTClaimsSet()); - } catch (ParseException e) { - return Mono.error(e); - } - }); - } else { - jwtDecoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build(); + throw new IllegalStateException( + "sra.oauth2.jwkSetUri must be configured for OAuth2 JWT verification." + + "SRA cannot securely operate without JWT signature verification."); } + + NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build(); jwtDecoder.setJwtValidator(oauth2JWTValidator); jwtDecoder.setClaimSetConverter(jwtClaimSetConverter); return jwtDecoder;
