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 <m...@philipptrenz.de>
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);
+    }
+}


Reply via email to