View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.syncope.core.spring.security.jws;
20  
21  import com.fasterxml.jackson.databind.json.JsonMapper;
22  import com.github.benmanes.caffeine.cache.CacheLoader;
23  import com.github.benmanes.caffeine.cache.Caffeine;
24  import com.github.benmanes.caffeine.cache.LoadingCache;
25  import com.nimbusds.jose.JOSEException;
26  import com.nimbusds.jose.JWSAlgorithm;
27  import com.nimbusds.jose.JWSHeader;
28  import com.nimbusds.jose.JWSVerifier;
29  import com.nimbusds.jose.crypto.ECDSAVerifier;
30  import com.nimbusds.jose.crypto.RSASSAVerifier;
31  import com.nimbusds.jose.jca.JCAAware;
32  import com.nimbusds.jose.jca.JCAContext;
33  import com.nimbusds.jose.jwk.AsymmetricJWK;
34  import com.nimbusds.jose.jwk.JWK;
35  import com.nimbusds.jose.jwk.JWKSet;
36  import com.nimbusds.jose.util.Base64URL;
37  import java.io.IOException;
38  import java.net.URI;
39  import java.net.http.HttpClient;
40  import java.net.http.HttpRequest;
41  import java.net.http.HttpResponse;
42  import java.security.PublicKey;
43  import java.security.interfaces.ECPublicKey;
44  import java.security.interfaces.RSAPublicKey;
45  import java.text.ParseException;
46  import java.time.Duration;
47  import java.util.HashMap;
48  import java.util.List;
49  import java.util.Map;
50  import java.util.Optional;
51  import java.util.Set;
52  import java.util.stream.Collectors;
53  import org.apache.commons.lang3.StringUtils;
54  import org.slf4j.Logger;
55  import org.slf4j.LoggerFactory;
56  
57  public class MSEntraAccessTokenJWSVerifier implements JWSVerifier {
58  
59      protected static final Logger LOG = LoggerFactory.getLogger(MSEntraAccessTokenJWSVerifier.class);
60  
61      protected final String tenantId;
62  
63      protected final String appId;
64  
65      protected final Duration cacheExpireAfterWrite;
66  
67      protected final HttpClient httpClient;
68  
69      protected final JsonMapper jsonMapper;
70  
71      protected final LoadingCache<String, JWSVerifier> verifiersCache;
72  
73      public MSEntraAccessTokenJWSVerifier(
74              final String tenantId,
75              final String appId,
76              final Duration cacheExpireAfterWrite) {
77  
78          this.tenantId = tenantId;
79          this.appId = appId;
80          this.cacheExpireAfterWrite = cacheExpireAfterWrite;
81  
82          this.httpClient = HttpClient.newHttpClient();
83          this.jsonMapper = JsonMapper.builder().findAndAddModules().build();
84  
85          /*
86           * At any given point in time, Entra ID (formerly: Azure AD) may sign an ID token using
87           * any one of a certain set of public-private key pairs. Entra ID rotates the possible
88           * set of keys on a periodic basis, so the application should be written to handle those
89           * key changes automatically. A reasonable frequency to check for updates to the public
90           * keys used by Entra ID is every 24 hours.
91           */
92          this.verifiersCache = Caffeine.newBuilder().
93                  expireAfterWrite(cacheExpireAfterWrite).
94                  build(new CacheLoader<>() {
95  
96                      @Override
97                      public JWSVerifier load(final String key) {
98                          return loadAll(List.of(key)).get(key);
99                      }
100 
101                     @Override
102                     public Map<String, JWSVerifier> loadAll(final Iterable<? extends String> keys) {
103                         // Ignore keys argument, as we have to fetch the full JSON Web Key Set
104                         String openIdDocUrl = getOpenIDMetadataDocumentUrl();
105                         String openIdDoc = fetchDocument(openIdDocUrl);
106                         String jwksUri = extractJwksUri(openIdDoc);
107                         String jwks = fetchDocument(jwksUri);
108 
109                         return parseJsonWebKeySet(jwks);
110                     }
111                 });
112     }
113 
114     protected String getOpenIDMetadataDocumentUrl() {
115         return String.format(
116                 "https://login.microsoftonline.com/%s/.well-known/openid-configuration%s",
117                 Optional.ofNullable(tenantId).orElse("common"),
118                 Optional.ofNullable(appId).map(i -> String.format("?appid=%s", i)).orElse(""));
119     }
120 
121     protected String extractJwksUri(final String openIdMetadataDocument) {
122         try {
123             return jsonMapper.readTree(openIdMetadataDocument).get("jwks_uri").asText();
124         } catch (IOException e) {
125             throw new IllegalArgumentException("Extracting value of 'jwks_url' key from OpenID Metadata JSON document"
126                     + " for Microsoft Entra failed:", e);
127         }
128     }
129 
130     protected String fetchDocument(final String url) {
131         HttpResponse<String> response;
132         try {
133             response = httpClient.send(
134                     HttpRequest.newBuilder().uri(URI.create(url)).build(),
135                     HttpResponse.BodyHandlers.ofString());
136             if (response.statusCode() >= 400) {
137                 throw new IllegalStateException(String.format("Received HTTP status code %d", response.statusCode()));
138             }
139             return response.body();
140         } catch (IOException | InterruptedException | IllegalStateException e) {
141             throw new IllegalStateException(
142                     String.format("Fetching JSON document for Microsoft Entra from '%s' failed:", url), e);
143         }
144     }
145 
146     protected Map<String, JWSVerifier> parseJsonWebKeySet(final String jsonWebKeySet) {
147         List<JWK> fetchedKeys;
148         try {
149             fetchedKeys = JWKSet.parse(jsonWebKeySet).getKeys();
150         } catch (ParseException e) {
151             throw new IllegalArgumentException("Parsing JSON Web Key Set for MS Entra failed:", e);
152         }
153 
154         Map<String, JWSVerifier> verifiers = new HashMap<>();
155         for (JWK key : fetchedKeys) {
156             if (!(key instanceof AsymmetricJWK)) {
157                 LOG.warn(
158                         "Skipped non-asymmetric JSON Web Key with key id '{}' from retrieved JSON Web Key Set "
159                         + "for Microsoft Entra", key.getKeyID());
160                 continue;
161             }
162 
163             try {
164                 PublicKey pubKey = ((AsymmetricJWK) key).toPublicKey();
165                 if (pubKey instanceof RSAPublicKey) {
166                     verifiers.put(
167                             key.getKeyID(),
168                             new RSASSAVerifier((RSAPublicKey) pubKey));
169                 } else if (pubKey instanceof ECPublicKey) {
170                     verifiers.put(
171                             key.getKeyID(),
172                             new ECDSAVerifier((ECPublicKey) pubKey));
173                 }
174             } catch (JOSEException e) {
175                 throw new IllegalArgumentException(
176                         "Extracting public key from asymmetric JSON Web Key from retrieved JSON Web Key Set for"
177                         + " Microsoft Entra failed:", e);
178             }
179         }
180 
181         return verifiers;
182     }
183 
184     protected Map<String, JWSVerifier> getAllFromCache() {
185         // Ensure cache is populated and gets refreshed, if expired
186         verifiersCache.getAll(Set.of(StringUtils.EMPTY));
187 
188         return verifiersCache.asMap();
189     }
190 
191     @Override
192     public Set<JWSAlgorithm> supportedJWSAlgorithms() {
193         return getAllFromCache().
194                 values().stream().
195                 flatMap(jwsVerifier -> jwsVerifier.supportedJWSAlgorithms().stream()).
196                 collect(Collectors.toSet());
197     }
198 
199     @Override
200     public JCAContext getJCAContext() {
201         return getAllFromCache().
202                 values().stream().
203                 map(JCAAware::getJCAContext).
204                 findFirst().
205                 orElseThrow(() -> new IllegalStateException("JSON Web Key Set cache for Microsoft Entra is empty"));
206     }
207 
208     @Override
209     public boolean verify(
210             final JWSHeader header,
211             final byte[] signingInput,
212             final Base64URL signature) throws JOSEException {
213 
214         String keyId = header.getKeyID();
215         JWSVerifier delegate = Optional.ofNullable(verifiersCache.get(keyId)).
216                 orElseThrow(() -> new JOSEException(
217                 String.format("Microsoft Entra JSON Web Key Set cache could not retrieve a public key for "
218                         + "given key id '%s'", keyId)));
219 
220         return delegate.verify(header, signingInput, signature);
221     }
222 }