1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
87
88
89
90
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
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
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 }