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); + } +}