This is an automated email from the ASF dual-hosted git repository. ilgrosso pushed a commit to branch 4_1_X in repository https://gitbox.apache.org/repos/asf/syncope.git
commit 92de7f1b0aebe07fe192561cf5045f9f0dafec4e 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 | 88 +--------------------- .../apache/syncope/fit/sra/OAUTH2SRAITCase.java | 5 -- .../org/apache/syncope/fit/sra/OIDCSRAITCase.java | 86 +++++++++++++++++++-- .../src/test/resources/sra-oauth2.properties | 2 +- .../org/apache/syncope/sra/SecurityConfig.java | 17 ++--- 6 files changed, 89 insertions(+), 111 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 e8ee0f62aa..27516f9a7e 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.Set; import java.util.concurrent.TimeUnit; import org.apache.cxf.jaxrs.client.WebClient; @@ -69,7 +60,6 @@ import org.apache.syncope.common.lib.types.OIDCSubjectType; import org.apache.syncope.common.lib.types.PolicyType; import org.apache.syncope.common.rest.api.RESTHeaders; import org.apache.syncope.common.rest.api.service.wa.WAConfigService; -import org.apereo.cas.support.oauth.OAuth20Constants; import org.jsoup.Jsoup; import org.junit.jupiter.api.Test; @@ -99,7 +89,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(); @@ -286,80 +276,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(OAuth20Constants.GRANT_TYPE, OIDCGrantType.password.getExternalForm()). - param(OAuth20Constants.CLIENT_ID, SRA_CLIENT_ID). - param(OAuth20Constants.CLIENT_SECRET, SRA_CLIENT_SECRET). - param("username", "verdi"). - param("password", "password"). - param(OAuth20Constants.SCOPE, "openid profile email " + GROUPS_SCOPE); - 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 aa2689b0fe..dac2827b0b 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 2e1c026ddb..efdae0f753 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,6 +18,9 @@ */ 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.assertNull; @@ -27,13 +30,19 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.fasterxml.jackson.core.JsonProcessingException; 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.text.ParseException; +import java.util.List; +import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.concurrent.TimeoutException; @@ -133,11 +142,6 @@ class OIDCSRAITCase extends AbstractOIDCITCase { OIDCSRAITCase.class.getName(), SRA_REGISTRATION_ID, CLIENT_APP_ID, SRA_CLIENT_ID, SRA_CLIENT_SECRET); } - @Override - protected boolean checkIdToken() { - return true; - } - @Test public void clientCredentials() throws JsonProcessingException, ParseException { WebClient webclient = WebClient.create(WA_ADDRESS + "/oidc/oidcAccessToken"); @@ -161,4 +165,76 @@ class OIDCSRAITCase extends AbstractOIDCITCase { assertNotNull(idToken.getJWTClaimsSet().getClaim(CUSTOM_CLAIM1)); assertNull(idToken.getJWTClaimsSet().getClaim(CUSTOM_CLAIM2)); } + + @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"))); + } + + @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(OAuth20Constants.GRANT_TYPE, OIDCGrantType.password.getExternalForm()). + param(OAuth20Constants.CLIENT_ID, SRA_CLIENT_ID). + param(OAuth20Constants.CLIENT_SECRET, SRA_CLIENT_SECRET). + param("username", "verdi"). + param("password", "password"). + param(OAuth20Constants.SCOPE, "openid profile email " + GROUPS_SCOPE); + 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 d0bfcc5c66..9a2644363f 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;
