This is an automated email from the ASF dual-hosted git repository.
ilgrosso pushed a commit to branch 3_0_X
in repository https://gitbox.apache.org/repos/asf/syncope.git
The following commit(s) were added to refs/heads/3_0_X by this push:
new 7d95eecba0 Add JWTSSOProvider for Microsoft Entra (formerly Azure)
access tokens (#580)
7d95eecba0 is described below
commit 7d95eecba03bd0b88435da6080177111c994c440
Author: Philipp Trenz <[email protected]>
AuthorDate: Tue Dec 19 16:16:51 2023 +0100
Add JWTSSOProvider for Microsoft Entra (formerly Azure) access tokens (#580)
---
core/provisioning-java/pom.xml | 5 -
core/spring/pom.xml | 15 +
.../spring/security/MSEntraJWTSSOProvider.java | 134 +++++++++
.../jws/MSEntraAccessTokenJWSVerifier.java | 222 ++++++++++++++
.../spring/security/MSEntraJWTSSOProviderTest.java | 312 +++++++++++++++++++
.../jws/MSEntraAccessTokenJWSVerifierTest.java | 329 +++++++++++++++++++++
6 files changed, 1012 insertions(+), 5 deletions(-)
diff --git a/core/provisioning-java/pom.xml b/core/provisioning-java/pom.xml
index 0db75359a1..29a0a8da92 100644
--- a/core/provisioning-java/pom.xml
+++ b/core/provisioning-java/pom.xml
@@ -58,11 +58,6 @@ under the License.
<artifactId>spring-jdbc</artifactId>
</dependency>
- <dependency>
- <groupId>com.github.ben-manes.caffeine</groupId>
- <artifactId>caffeine</artifactId>
- </dependency>
-
<dependency>
<groupId>org.apache.geronimo.javamail</groupId>
<artifactId>geronimo-javamail_1.4_mail</artifactId>
diff --git a/core/spring/pom.xml b/core/spring/pom.xml
index 3874382231..5ada2c384a 100644
--- a/core/spring/pom.xml
+++ b/core/spring/pom.xml
@@ -85,6 +85,11 @@ under the License.
<artifactId>java-uuid-generator</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.github.ben-manes.caffeine</groupId>
+ <artifactId>caffeine</artifactId>
+ </dependency>
+
<dependency>
<groupId>org.apache.syncope.core</groupId>
<artifactId>syncope-core-provisioning-api</artifactId>
@@ -117,11 +122,21 @@ under the License.
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-inline</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/MSEntraJWTSSOProvider.java
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/MSEntraJWTSSOProvider.java
new file mode 100644
index 0000000000..79accec85c
--- /dev/null
+++
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/MSEntraJWTSSOProvider.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.core.spring.security;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.jca.JCAContext;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Set;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.syncope.core.persistence.api.dao.UserDAO;
+import org.apache.syncope.core.persistence.api.entity.user.User;
+import
org.apache.syncope.core.spring.security.jws.MSEntraAccessTokenJWSVerifier;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * JWT authorisation for access tokens issued by Microsoft Entra (formerly
Azure)
+ * for Microsoft Entra-only applications (v1.0 tokens)
+ * cf. https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
+ */
+public class MSEntraJWTSSOProvider implements JWTSSOProvider {
+
+ protected final UserDAO userDAO;
+
+ protected final AuthDataAccessor authDataAccessor;
+
+ protected final String tenantId;
+
+ protected final String appId;
+
+ protected final String authUsername;
+
+ protected final Duration clockSkew;
+
+ protected final MSEntraAccessTokenJWSVerifier verifier;
+
+ public MSEntraJWTSSOProvider(
+ final UserDAO userDAO,
+ final AuthDataAccessor authDataAccessor,
+ final String tenantId,
+ final String appId,
+ final String authUsername,
+ final Duration clockSkew,
+ final MSEntraAccessTokenJWSVerifier verifier) {
+
+ this.userDAO = userDAO;
+ this.authDataAccessor = authDataAccessor;
+ this.tenantId = tenantId;
+ this.appId = appId;
+ this.authUsername = authUsername;
+ this.clockSkew = clockSkew;
+ this.verifier = verifier;
+ }
+
+ @Override
+ public String getIssuer() {
+ return String.format("https://sts.windows.net/%s/", tenantId);
+ }
+
+ @Override
+ public Set<JWSAlgorithm> supportedJWSAlgorithms() {
+ return verifier.supportedJWSAlgorithms();
+ }
+
+ @Override
+ public JCAContext getJCAContext() {
+ return verifier.getJCAContext();
+ }
+
+ /*
+ * When parsing the token, you must [...] ensure the token meets these
requirements:
+ *
+ * - The token was sent in the HTTP Authorization header with "Bearer"
scheme.
+ * - The token is valid JSON that conforms to the JWT standard.
+ * - The token contains an "issuer" claim with one of the highlighted
values for non-governmental cases.
+ * - The token contains an "audience" claim with a value equal to the
Microsoft App ID.
+ * - The token is within its validity period. Industry-standard clock-skew
is 5 minutes.
+ * - The token has a valid cryptographic signature with a key listed in
the OpenID keys document that was retrieved
+ * from the `jwks_uri` property in the OpenID metadata document via GET
request.
+ *
+ * cf.
https://learn.microsoft.com/en-us/entra/identity-platform/security-tokens#validate-security-tokens
+ */
+ @Override
+ public boolean verify(final JWSHeader header, final byte[] signingInput,
final Base64URL signature)
+ throws JOSEException {
+
+ return verifier.verify(header, signingInput, signature);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public Pair<User, Set<SyncopeGrantedAuthority>> resolve(final JWTClaimsSet
jwtClaims) {
+ User authUser = userDAO.findByUsername(authUsername);
+ Set<SyncopeGrantedAuthority> authorities = Set.of();
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = jwtClaims.getIssueTime().toInstant();
+ Instant notBefore = jwtClaims.getNotBeforeTime().toInstant();
+ Instant expired = jwtClaims.getExpirationTime().toInstant();
+
+ if (authUser != null
+ && jwtClaims.getAudience().contains(appId)
+ && now.isAfter(issued.minus(clockSkew))
+ && now.isAfter(notBefore.minus(clockSkew))
+ && now.isBefore(expired.plus(clockSkew))) {
+
+ authorities =
authDataAccessor.getAuthorities(authUser.getUsername(), null);
+ }
+
+ return Pair.of(authUser, authorities);
+ }
+}
diff --git
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/jws/MSEntraAccessTokenJWSVerifier.java
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/jws/MSEntraAccessTokenJWSVerifier.java
new file mode 100644
index 0000000000..dc1c4dfe5f
--- /dev/null
+++
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/jws/MSEntraAccessTokenJWSVerifier.java
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.core.spring.security.jws;
+
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.github.benmanes.caffeine.cache.CacheLoader;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.ECDSAVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.jca.JCAAware;
+import com.nimbusds.jose.jca.JCAContext;
+import com.nimbusds.jose.jwk.AsymmetricJWK;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.util.Base64URL;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.PublicKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.text.ParseException;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class MSEntraAccessTokenJWSVerifier implements JWSVerifier {
+
+ protected static final Logger LOG =
LoggerFactory.getLogger(MSEntraAccessTokenJWSVerifier.class);
+
+ protected final String tenantId;
+
+ protected final String appId;
+
+ protected final Duration cacheExpireAfterWrite;
+
+ protected final HttpClient httpClient;
+
+ protected final JsonMapper jsonMapper;
+
+ protected final LoadingCache<String, JWSVerifier> verifiersCache;
+
+ public MSEntraAccessTokenJWSVerifier(
+ final String tenantId,
+ final String appId,
+ final Duration cacheExpireAfterWrite) {
+
+ this.tenantId = tenantId;
+ this.appId = appId;
+ this.cacheExpireAfterWrite = cacheExpireAfterWrite;
+
+ this.httpClient = HttpClient.newHttpClient();
+ this.jsonMapper = JsonMapper.builder().findAndAddModules().build();
+
+ /*
+ * At any given point in time, Entra ID (formerly: Azure AD) may sign
an ID token using
+ * any one of a certain set of public-private key pairs. Entra ID
rotates the possible
+ * set of keys on a periodic basis, so the application should be
written to handle those
+ * key changes automatically. A reasonable frequency to check for
updates to the public
+ * keys used by Entra ID is every 24 hours.
+ */
+ this.verifiersCache = Caffeine.newBuilder().
+ expireAfterWrite(cacheExpireAfterWrite).
+ build(new CacheLoader<>() {
+
+ @Override
+ public JWSVerifier load(final String key) {
+ return loadAll(List.of(key)).get(key);
+ }
+
+ @Override
+ public Map<String, JWSVerifier> loadAll(final Iterable<?
extends String> keys) {
+ // Ignore keys argument, as we have to fetch the full
JSON Web Key Set
+ String openIdDocUrl = getOpenIDMetadataDocumentUrl();
+ String openIdDoc = fetchDocument(openIdDocUrl);
+ String jwksUri = extractJwksUri(openIdDoc);
+ String jwks = fetchDocument(jwksUri);
+
+ return parseJsonWebKeySet(jwks);
+ }
+ });
+ }
+
+ protected String getOpenIDMetadataDocumentUrl() {
+ return String.format(
+
"https://login.microsoftonline.com/%s/.well-known/openid-configuration%s",
+ Optional.ofNullable(tenantId).orElse("common"),
+ Optional.ofNullable(appId).map(i -> String.format("?appid=%s",
i)).orElse(""));
+ }
+
+ protected String extractJwksUri(final String openIdMetadataDocument) {
+ try {
+ return
jsonMapper.readTree(openIdMetadataDocument).get("jwks_uri").asText();
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Extracting value of 'jwks_url'
key from OpenID Metadata JSON document"
+ + " for Microsoft Entra failed:", e);
+ }
+ }
+
+ protected String fetchDocument(final String url) {
+ HttpResponse<String> response;
+ try {
+ response = httpClient.send(
+ HttpRequest.newBuilder().uri(URI.create(url)).build(),
+ HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() >= 400) {
+ throw new IllegalStateException(String.format("Received HTTP
status code %d", response.statusCode()));
+ }
+ return response.body();
+ } catch (IOException | InterruptedException | IllegalStateException e)
{
+ throw new IllegalStateException(
+ String.format("Fetching JSON document for Microsoft Entra
from '%s' failed:", url), e);
+ }
+ }
+
+ protected Map<String, JWSVerifier> parseJsonWebKeySet(final String
jsonWebKeySet) {
+ List<JWK> fetchedKeys;
+ try {
+ fetchedKeys = JWKSet.parse(jsonWebKeySet).getKeys();
+ } catch (ParseException e) {
+ throw new IllegalArgumentException("Parsing JSON Web Key Set for
MS Entra failed:", e);
+ }
+
+ Map<String, JWSVerifier> verifiers = new HashMap<>();
+ for (JWK key : fetchedKeys) {
+ if (!(key instanceof AsymmetricJWK)) {
+ LOG.warn(
+ "Skipped non-asymmetric JSON Web Key with key id '{}'
from retrieved JSON Web Key Set "
+ + "for Microsoft Entra", key.getKeyID());
+ continue;
+ }
+
+ try {
+ PublicKey pubKey = ((AsymmetricJWK) key).toPublicKey();
+ if (pubKey instanceof RSAPublicKey) {
+ verifiers.put(
+ key.getKeyID(),
+ new RSASSAVerifier((RSAPublicKey) pubKey));
+ } else if (pubKey instanceof ECPublicKey) {
+ verifiers.put(
+ key.getKeyID(),
+ new ECDSAVerifier((ECPublicKey) pubKey));
+ }
+ } catch (JOSEException e) {
+ throw new IllegalArgumentException(
+ "Extracting public key from asymmetric JSON Web Key
from retrieved JSON Web Key Set for"
+ + " Microsoft Entra failed:", e);
+ }
+ }
+
+ return verifiers;
+ }
+
+ protected Map<String, JWSVerifier> getAllFromCache() {
+ // Ensure cache is populated and gets refreshed, if expired
+ verifiersCache.getAll(Set.of(StringUtils.EMPTY));
+
+ return verifiersCache.asMap();
+ }
+
+ @Override
+ public Set<JWSAlgorithm> supportedJWSAlgorithms() {
+ return getAllFromCache().
+ values().stream().
+ flatMap(jwsVerifier ->
jwsVerifier.supportedJWSAlgorithms().stream()).
+ collect(Collectors.toSet());
+ }
+
+ @Override
+ public JCAContext getJCAContext() {
+ return getAllFromCache().
+ values().stream().
+ map(JCAAware::getJCAContext).
+ findFirst().
+ orElseThrow(() -> new IllegalStateException("JSON Web Key Set
cache for Microsoft Entra is empty"));
+ }
+
+ @Override
+ public boolean verify(
+ final JWSHeader header,
+ final byte[] signingInput,
+ final Base64URL signature) throws JOSEException {
+
+ String keyId = header.getKeyID();
+ JWSVerifier delegate = Optional.ofNullable(verifiersCache.get(keyId)).
+ orElseThrow(() -> new JOSEException(
+ String.format("Microsoft Entra JSON Web Key Set cache could
not retrieve a public key for "
+ + "given key id '%s'", keyId)));
+
+ return delegate.verify(header, signingInput, signature);
+ }
+}
diff --git
a/core/spring/src/test/java/org/apache/syncope/core/spring/security/MSEntraJWTSSOProviderTest.java
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/MSEntraJWTSSOProviderTest.java
new file mode 100644
index 0000000000..c44264a833
--- /dev/null
+++
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/MSEntraJWTSSOProviderTest.java
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.core.spring.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Set;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.syncope.core.persistence.api.dao.UserDAO;
+import org.apache.syncope.core.persistence.api.entity.user.User;
+import
org.apache.syncope.core.spring.security.jws.MSEntraAccessTokenJWSVerifier;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class MSEntraJWTSSOProviderTest {
+
+ private static final String TENANT_ID = "test-tenant-id";
+
+ private static final String APP_ID = "test-app-id";
+
+ private static final String AUTH_USERNAME = "auth-username";
+
+ private static final MSEntraAccessTokenJWSVerifier VERIFIER = new
MSEntraAccessTokenJWSVerifier(
+ TENANT_ID, APP_ID, Duration.ofHours(24));
+
+ @Mock
+ private User user;
+
+ @Mock
+ private UserDAO userDAO;
+
+ @Mock
+ private AuthDataAccessor authDataAccessor;
+
+ @Test
+ void getIssuer() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ assertEquals(provider.getIssuer(), "https://sts.windows.net/" +
TENANT_ID + "/");
+ }
+
+ @Test
+ void resolveSuccess() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+ when(authDataAccessor.getAuthorities(AUTH_USERNAME, null)).
+ thenReturn(Set.of(mock(SyncopeGrantedAuthority.class)));
+ when(user.getUsername()).thenReturn(AUTH_USERNAME);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(65, ChronoUnit.SECONDS);
+ Instant notBefore = now.minus(5, ChronoUnit.SECONDS);
+ Instant expiration = now.plus(1, ChronoUnit.HOURS);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ Pair<User, Set<SyncopeGrantedAuthority>> resolved =
provider.resolve(payload);
+ assertEquals(AUTH_USERNAME, resolved.getKey().getUsername());
+ assertEquals(1, resolved.getValue().size());
+ }
+
+ @Test
+ void resolveMissingClaims() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .build();
+
+ assertThrows(Exception.class, () -> provider.resolve(payload));
+ }
+
+ @Test
+ void resolveAuthUserNull() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(null);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(1, ChronoUnit.MINUTES);
+ Instant notBefore = now.minus(1, ChronoUnit.SECONDS);
+ Instant expiration = now.plus(59, ChronoUnit.MINUTES);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ Pair<User, Set<SyncopeGrantedAuthority>> resolved =
provider.resolve(payload);
+ assertNull(resolved.getKey());
+ assertTrue(resolved.getValue().isEmpty());
+ }
+
+ @Test
+ void resolveWrongAudience() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(1, ChronoUnit.MINUTES);
+ Instant notBefore = now.minus(1, ChronoUnit.SECONDS);
+ Instant expiration = now.plus(59, ChronoUnit.MINUTES);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience("wrong-audience-claim")
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertTrue(provider.resolve(payload).getValue().isEmpty());
+ }
+
+ @Test
+ void resolveIssuedFail() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.plus(6, ChronoUnit.MINUTES);
+ Instant notBefore = now.minus(5, ChronoUnit.SECONDS);
+ Instant expiration = now.plus(1, ChronoUnit.HOURS);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertTrue(provider.resolve(payload).getValue().isEmpty());
+ }
+
+ @Test
+ void resolveIssuedInClockSkew() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+ when(authDataAccessor.getAuthorities(AUTH_USERNAME, null)).
+ thenReturn(Set.of(mock(SyncopeGrantedAuthority.class)));
+ when(user.getUsername()).thenReturn(AUTH_USERNAME);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.plus(4, ChronoUnit.MINUTES);
+ Instant notBefore = now.minus(5, ChronoUnit.SECONDS);
+ Instant expiration = now.plus(1, ChronoUnit.HOURS);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertEquals(1, provider.resolve(payload).getValue().size());
+ }
+
+ @Test
+ void resolveNotBeforeFail() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(1, ChronoUnit.MINUTES);
+ Instant notBefore = now.plus(6, ChronoUnit.MINUTES);
+ Instant expiration = now.plus(1, ChronoUnit.HOURS);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertTrue(provider.resolve(payload).getValue().isEmpty());
+ }
+
+ @Test
+ void resolveNotBeforeInClockSkew() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+ when(authDataAccessor.getAuthorities(AUTH_USERNAME, null)).
+ thenReturn(Set.of(mock(SyncopeGrantedAuthority.class)));
+ when(user.getUsername()).thenReturn(AUTH_USERNAME);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(1, ChronoUnit.MINUTES);
+ Instant notBefore = now.plus(4, ChronoUnit.MINUTES);
+ Instant expiration = now.plus(1, ChronoUnit.HOURS);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertEquals(1, provider.resolve(payload).getValue().size());
+ }
+
+ @Test
+ void resolveExpirationFail() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(1, ChronoUnit.HOURS);
+ Instant notBefore = now.minus(1, ChronoUnit.HOURS);
+ Instant expiration = now.minus(6, ChronoUnit.MINUTES);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertTrue(provider.resolve(payload).getValue().isEmpty());
+ }
+
+ @Test
+ void resolveExpirationInClockSkew() {
+ MSEntraJWTSSOProvider provider = new MSEntraJWTSSOProvider(
+ userDAO, authDataAccessor, TENANT_ID, APP_ID, AUTH_USERNAME,
Duration.ofMinutes(5), VERIFIER);
+
+ when(userDAO.findByUsername(anyString())).thenReturn(user);
+ when(authDataAccessor.getAuthorities(AUTH_USERNAME, null)).
+ thenReturn(Set.of(mock(SyncopeGrantedAuthority.class)));
+ when(user.getUsername()).thenReturn(AUTH_USERNAME);
+
+ Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
+ Instant issued = now.minus(1, ChronoUnit.HOURS);
+ Instant notBefore = now.minus(1, ChronoUnit.HOURS);
+ Instant expiration = now.minus(4, ChronoUnit.MINUTES);
+
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .issueTime(Date.from(issued))
+ .notBeforeTime(Date.from(notBefore))
+ .expirationTime(Date.from(expiration))
+ .build();
+
+ assertEquals(1, provider.resolve(payload).getValue().size());
+ }
+}
diff --git
a/core/spring/src/test/java/org/apache/syncope/core/spring/security/jws/MSEntraAccessTokenJWSVerifierTest.java
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/jws/MSEntraAccessTokenJWSVerifierTest.java
new file mode 100644
index 0000000000..eaa80bcb67
--- /dev/null
+++
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/jws/MSEntraAccessTokenJWSVerifierTest.java
@@ -0,0 +1,329 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.core.spring.security.jws;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.spy;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Duration;
+import java.util.Date;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class MSEntraAccessTokenJWSVerifierTest {
+
+ private static class SpyableMSEntraAccessTokenJWSVerifier extends
MSEntraAccessTokenJWSVerifier {
+
+ SpyableMSEntraAccessTokenJWSVerifier() {
+ super(null, null, Duration.ofHours(24));
+ }
+ }
+
+ private static final String TENANT_ID = "test-tenant-id";
+
+ private static final String APP_ID = "test-app-id";
+
+ private static String createSignedJWT(final JWK jwk) throws JOSEException {
+ // Create JWT header
+ JWSHeader header = new JWSHeader.Builder((JWSAlgorithm)
jwk.getAlgorithm())
+ .type(JOSEObjectType.JWT)
+ .keyID(jwk.getKeyID())
+ .build();
+
+ // Create JWT payload
+ JWTClaimsSet payload = new JWTClaimsSet.Builder()
+ .issuer(TENANT_ID)
+ .audience(APP_ID)
+ .build();
+
+ // Create signed JWT
+ SignedJWT signedJWT = new SignedJWT(header, payload);
+
+ JWSSigner signer = jwk.getAlgorithm() == JWSAlgorithm.RS256
+ ? new RSASSASigner(jwk.toRSAKey())
+ : new ECDSASigner(jwk.toECKey());
+
+ signedJWT.sign(signer);
+ return signedJWT.serialize();
+ }
+
+ private static MSEntraAccessTokenJWSVerifier getSpyInstance(
+ final String jwksUri, final String oidc, final String jwks) {
+
+ MSEntraAccessTokenJWSVerifier v =
spy(SpyableMSEntraAccessTokenJWSVerifier.class);
+ doAnswer(m -> m.getArgument(0).equals(jwksUri) ? jwks :
oidc).when(v).fetchDocument(anyString());
+ return v;
+ }
+
+ private static JWK generateJWKRSA() throws NoSuchAlgorithmException {
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(2048);
+ KeyPair keyPair = gen.generateKeyPair();
+
+ // Convert to JWK format
+ return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
+ .privateKey((RSAPrivateKey) keyPair.getPrivate())
+ .keyUse(KeyUse.SIGNATURE)
+ .algorithm(JWSAlgorithm.RS256)
+ .keyID(UUID.randomUUID().toString())
+ .issueTime(new Date())
+ .build();
+ }
+
+ private static JWK generateJWKEC() throws NoSuchAlgorithmException,
InvalidAlgorithmParameterException {
+ // Generate EC key pair with P-256 curve
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("EC");
+ gen.initialize(Curve.P_256.toECParameterSpec());
+ KeyPair keyPair = gen.generateKeyPair();
+
+ // Convert to JWK format
+ return new ECKey.Builder(Curve.P_256, (ECPublicKey)
keyPair.getPublic())
+ .privateKey((ECPrivateKey) keyPair.getPrivate())
+ .algorithm(JWSAlgorithm.ES256)
+ .keyUse(KeyUse.SIGNATURE)
+ .keyID(UUID.randomUUID().toString())
+ .issueTime(new Date())
+ .build();
+ }
+
+ @Test
+ void getOpenIDMetadataDocumentUrl() {
+ // Tenant id and app id
+ MSEntraAccessTokenJWSVerifier v1 = new
MSEntraAccessTokenJWSVerifier(TENANT_ID, APP_ID, Duration.ofHours(24));
+ assertEquals(String.format(
+
"https://login.microsoftonline.com/%s/.well-known/openid-configuration?appid=%s",
TENANT_ID, APP_ID),
+ v1.getOpenIDMetadataDocumentUrl());
+
+ // Tenant id, no app id
+ MSEntraAccessTokenJWSVerifier v2 = new
MSEntraAccessTokenJWSVerifier(TENANT_ID, null, Duration.ofHours(24));
+ assertEquals(
+
String.format("https://login.microsoftonline.com/%s/.well-known/openid-configuration",
TENANT_ID),
+ v2.getOpenIDMetadataDocumentUrl());
+
+ // No tenant id, no app id
+ MSEntraAccessTokenJWSVerifier v3 = new
MSEntraAccessTokenJWSVerifier(null, null, Duration.ofHours(24));
+ assertEquals(
+
"https://login.microsoftonline.com/common/.well-known/openid-configuration",
+ v3.getOpenIDMetadataDocumentUrl());
+ }
+
+ @Test
+ void extractJwksUri() {
+ String doc = "{\"jwks_uri\":
\"https://login.microsoftonline.com/common/discovery/keys\"}";
+
+ MSEntraAccessTokenJWSVerifier v = new
MSEntraAccessTokenJWSVerifier(TENANT_ID, APP_ID, Duration.ofHours(24));
+
assertEquals("https://login.microsoftonline.com/common/discovery/keys",
v.extractJwksUri(doc));
+ }
+
+ @Test
+ void parseJsonWebKeySetRSA() throws Exception {
+ // Create JWK, JWKS and jwt string
+ JWK jwk = generateJWKRSA();
+ String jwks = "{\"keys\": [" + jwk.toPublicJWK().toJSONString() + "]}";
+ String jwt = createSignedJWT(jwk);
+
+ // Create JWSVerifier
+ MSEntraAccessTokenJWSVerifier v = new MSEntraAccessTokenJWSVerifier(
+ "unknown-tenant-id", null, Duration.ofHours(24));
+
+ assertDoesNotThrow(() -> v.parseJsonWebKeySet(jwks));
+
+ Map<String, JWSVerifier> verifiersMap = v.parseJsonWebKeySet(jwks);
+ assertEquals(1, verifiersMap.size());
+ JWSVerifier v1 = verifiersMap.get(jwk.getKeyID());
+ assertNotNull(v1);
+ assertTrue(v1.supportedJWSAlgorithms().contains((JWSAlgorithm)
jwk.getAlgorithm()));
+
+ // Verify JWT
+ String[] chunks = jwt.split("\\.");
+ assertTrue(v1.verify(
+ JWSHeader.parse(new Base64URL(chunks[0])),
+ (chunks[0] + "." + chunks[1]).getBytes(),
+ new Base64URL(chunks[2])));
+ }
+
+ @Test
+ void parseJsonWebKeySetEC() throws Exception {
+ // Create JWK, JWKS and jwt string
+ JWK jwk = generateJWKEC();
+ String jwks = "{\"keys\": [" + jwk.toPublicJWK().toJSONString() + "]}";
+ String jwt = createSignedJWT(jwk);
+
+ // Create JWSVerifier
+ MSEntraAccessTokenJWSVerifier v = new MSEntraAccessTokenJWSVerifier(
+ "unknown-tenant-id", null, Duration.ofHours(24));
+
+ assertDoesNotThrow(() -> v.parseJsonWebKeySet(jwks));
+ Map<String, JWSVerifier> verifiersMap = v.parseJsonWebKeySet(jwks);
+ assertEquals(1, verifiersMap.size());
+ JWSVerifier v1 = verifiersMap.get(jwk.getKeyID());
+ assertNotNull(v1);
+ assertTrue(v1.supportedJWSAlgorithms().contains((JWSAlgorithm)
jwk.getAlgorithm()));
+
+ // Verify JWT
+ String[] chunks = jwt.split("\\.");
+ assertTrue(v1.verify(
+ JWSHeader.parse(new Base64URL(chunks[0])),
+ (chunks[0] + "." + chunks[1]).getBytes(),
+ new Base64URL(chunks[2])));
+ }
+
+ @Test
+ void supportedJWSAlgorithmsEmpty() {
+ String jwksUri = "https://example.com/keys";
+ String oidc = "{\"jwks_uri\": \"" + jwksUri + "\"}";
+ String jwks = "{\"keys\": []}";
+
+ MSEntraAccessTokenJWSVerifier v = getSpyInstance(jwksUri, oidc, jwks);
+
+ assertTrue(v.supportedJWSAlgorithms().isEmpty());
+ }
+
+ @Test
+ void supportedJWSAlgorithmsRSA() throws Exception {
+ JWK jwk = generateJWKRSA();
+ String[] chunks = createSignedJWT(jwk).split("\\.");
+
+ String jwksUri = "https://example.com/keys";
+ MSEntraAccessTokenJWSVerifier v = getSpyInstance(
+ jwksUri,
+ "{\"jwks_uri\": \"" + jwksUri + "\"}",
+ "{\"keys\": [" + jwk.toPublicJWK().toJSONString() + "]}");
+
+ assertTrue(v.verify(
+ JWSHeader.parse(new Base64URL(chunks[0])),
+ (chunks[0] + "." + chunks[1]).getBytes(),
+ new Base64URL(chunks[2])));
+ assertTrue(v.supportedJWSAlgorithms().contains((JWSAlgorithm)
jwk.getAlgorithm()));
+ assertDoesNotThrow(v::getJCAContext);
+ }
+
+ @Test
+ void supportedJWSAlgorithmsRSAJWSAlgorithm() throws Exception {
+ JWK jwk = generateJWKRSA();
+
+ String jwksUri = "https://example.com/keys";
+ MSEntraAccessTokenJWSVerifier v = getSpyInstance(
+ jwksUri,
+ "{\"jwks_uri\": \"" + jwksUri + "\"}",
+ "{\"keys\": [" + jwk.toPublicJWK().toJSONString() + "]}");
+
+ assertTrue(v.supportedJWSAlgorithms().contains((JWSAlgorithm)
jwk.getAlgorithm()));
+ }
+
+ @Test
+ void supportedJWSAlgorithmsRSAJCAContext() throws
NoSuchAlgorithmException, JOSEException {
+ JWK jwk = generateJWKRSA();
+
+ String jwksUri = "https://example.com/keys";
+ String oidc = "{\"jwks_uri\": \"" + jwksUri + "\"}";
+ String jwks = "{\"keys\": [" + jwk.toPublicJWK().toJSONString() + "]}";
+
+ MSEntraAccessTokenJWSVerifier v = getSpyInstance(jwksUri, oidc, jwks);
+
+ assertDoesNotThrow(v::getJCAContext);
+ }
+
+ @Test
+ void supportedJWSAlgorithmsEC() throws Exception {
+ JWK jwk = generateJWKEC();
+ String[] chunks = createSignedJWT(jwk).split("\\.");
+
+ String jwksUri = "https://example.com/keys";
+ MSEntraAccessTokenJWSVerifier v = getSpyInstance(
+ jwksUri,
+ "{\"jwks_uri\": \"" + jwksUri + "\"}",
+ "{\"keys\": [" + jwk.toPublicJWK().toJSONString() + "]}");
+
+ assertTrue(v.verify(
+ JWSHeader.parse(new Base64URL(chunks[0])),
+ (chunks[0] + "." + chunks[1]).getBytes(),
+ new Base64URL(chunks[2])
+ ));
+ assertTrue(v.supportedJWSAlgorithms().contains((JWSAlgorithm)
jwk.getAlgorithm()));
+ assertDoesNotThrow(v::getJCAContext);
+ }
+
+ @Test
+ void supportedJWSAlgorithmsMixed() throws Exception {
+ JWK jwkRSA = generateJWKRSA();
+ JWK jwkEC = generateJWKEC();
+ String[] chunksRSA = createSignedJWT(jwkRSA).split("\\.");
+ String[] chunksEC = createSignedJWT(jwkEC).split("\\.");
+
+ String jwksUri = "https://example.com/keys";
+ MSEntraAccessTokenJWSVerifier v = getSpyInstance(jwksUri,
+ "{\"jwks_uri\": \"" + jwksUri + "\"}",
+ "{\"keys\": ["
+ + jwkRSA.toPublicJWK().toJSONString() + ","
+ + jwkEC.toPublicJWK().toJSONString()
+ + "]}");
+
+ // Verify with RSA
+ assertTrue(v.verify(
+ JWSHeader.parse(new Base64URL(chunksRSA[0])),
+ (chunksRSA[0] + "." + chunksRSA[1]).getBytes(),
+ new Base64URL(chunksRSA[2])));
+
+ // Verify with EC
+ assertTrue(v.verify(
+ JWSHeader.parse(new Base64URL(chunksEC[0])),
+ (chunksEC[0] + "." + chunksEC[1]).getBytes(),
+ new Base64URL(chunksEC[2])));
+
+ Set.of((JWSAlgorithm) jwkRSA.getAlgorithm(), (JWSAlgorithm)
jwkEC.getAlgorithm()).
+ forEach(jwsAlgorithm ->
assertTrue(v.supportedJWSAlgorithms().contains(jwsAlgorithm)));
+
+ assertDoesNotThrow(v::getJCAContext);
+ }
+}