001/*
002 * nimbus-jose-jwt
003 *
004 * Copyright 2012-2016, Connect2id Ltd.
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use
007 * this file except in compliance with the License. You may obtain a copy of the
008 * License at
009 *
010 *    http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software distributed
013 * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
014 * CONDITIONS OF ANY KIND, either express or implied. See the License for the
015 * specific language governing permissions and limitations under the License.
016 */
017
018package com.nimbusds.jose.jwk.source;
019
020
021import java.io.IOException;
022import java.net.URL;
023import java.util.Collections;
024import java.util.List;
025import java.util.Set;
026
027import net.jcip.annotations.ThreadSafe;
028
029import com.nimbusds.jose.KeySourceException;
030import com.nimbusds.jose.RemoteKeySourceException;
031import com.nimbusds.jose.jwk.JWK;
032import com.nimbusds.jose.jwk.JWKMatcher;
033import com.nimbusds.jose.jwk.JWKSelector;
034import com.nimbusds.jose.jwk.JWKSet;
035import com.nimbusds.jose.proc.SecurityContext;
036import com.nimbusds.jose.util.DefaultResourceRetriever;
037import com.nimbusds.jose.util.Resource;
038import com.nimbusds.jose.util.ResourceRetriever;
039
040
041/**
042 * Remote JSON Web Key (JWK) source specified by a JWK set URL. The retrieved
043 * JWK set is cached to minimise network calls. The cache is updated whenever
044 * the key selector tries to get a key with an unknown ID or the cache expires.
045 *
046 * <p>If no {@link ResourceRetriever} is specified when creating a remote JWK
047 * set source the {@link DefaultResourceRetriever default one} will be used,
048 * with the following HTTP timeouts and limits:
049 *
050 * <ul>
051 *     <li>HTTP connect timeout, in milliseconds: Determined by the
052 *         {@link #DEFAULT_HTTP_CONNECT_TIMEOUT} constant which can be
053 *         overridden by setting the
054 *         {@code com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpConnectTimeout}
055 *         Java system property.
056 *     <li>HTTP read timeout, in milliseconds: Determined by the
057 *         {@link #DEFAULT_HTTP_READ_TIMEOUT} constant which can be
058 *         overridden by setting the
059 *         {@code com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpReadTimeout}
060 *         Java system property.
061 *     <li>HTTP entity size limit: Determined by the
062 *         {@link #DEFAULT_HTTP_SIZE_LIMIT} constant which can be
063 *         overridden by setting the
064 *         {@code com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpSizeLimit}
065 *         Java system property.
066 * </ul>
067 *
068 * <p>A failover JWK source can be configured in case the JWK set URL becomes
069 * unavailable (HTTP 404) or times out. The failover JWK source can be another
070 * URL or some other object.
071 *
072 * @author Vladimir Dzhuvinov
073 * @author Andreas Huber
074 * @version 2022-01-30
075 */
076@ThreadSafe
077public class RemoteJWKSet<C extends SecurityContext> implements JWKSource<C> {
078
079
080        /**
081         * The default HTTP connect timeout for JWK set retrieval, in
082         * milliseconds. Set to 500 milliseconds.
083         */
084        public static final int DEFAULT_HTTP_CONNECT_TIMEOUT = 500;
085
086
087        /**
088         * The default HTTP read timeout for JWK set retrieval, in
089         * milliseconds. Set to 500 milliseconds.
090         */
091        public static final int DEFAULT_HTTP_READ_TIMEOUT = 500;
092
093
094        /**
095         * The default HTTP entity size limit for JWK set retrieval, in bytes.
096         * Set to 50 KBytes.
097         */
098        public static final int DEFAULT_HTTP_SIZE_LIMIT = 50 * 1024;
099        
100        
101        /**
102         * Resolves the default HTTP connect timeout for JWK set retrieval, in
103         * milliseconds.
104         *
105         * @return The {@link #DEFAULT_HTTP_CONNECT_TIMEOUT static constant},
106         *         overridden by setting the
107         *         {@code com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpConnectTimeout}
108         *         Java system property.
109         */
110        public static int resolveDefaultHTTPConnectTimeout() {
111                return resolveDefault(RemoteJWKSet.class.getName() + ".defaultHttpConnectTimeout", DEFAULT_HTTP_CONNECT_TIMEOUT);
112        }
113        
114        
115        /**
116         * Resolves the default HTTP read timeout for JWK set retrieval, in
117         * milliseconds.
118         *
119         * @return The {@link #DEFAULT_HTTP_READ_TIMEOUT static constant},
120         *         overridden by setting the
121         *         {@code com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpReadTimeout}
122         *         Java system property.
123         */
124        public static int resolveDefaultHTTPReadTimeout() {
125                return resolveDefault(RemoteJWKSet.class.getName() + ".defaultHttpReadTimeout", DEFAULT_HTTP_READ_TIMEOUT);
126        }
127        
128        
129        /**
130         * Resolves default HTTP entity size limit for JWK set retrieval, in
131         * bytes.
132         *
133         * @return The {@link #DEFAULT_HTTP_SIZE_LIMIT static constant},
134         *         overridden by setting the
135         *         {@code com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpSizeLimit}
136         *         Java system property.
137         */
138        public static int resolveDefaultHTTPSizeLimit() {
139                return resolveDefault(RemoteJWKSet.class.getName() + ".defaultHttpSizeLimit", DEFAULT_HTTP_SIZE_LIMIT);
140        }
141        
142        
143        private static int resolveDefault(final String sysPropertyName, final int defaultValue) {
144                
145                String value = System.getProperty(sysPropertyName);
146                
147                if (value == null) {
148                        return defaultValue;
149                }
150                
151                try {
152                        return Integer.parseInt(value);
153                } catch (NumberFormatException e) {
154                        // Illegal value
155                        return defaultValue;
156                }
157        }
158
159
160        /**
161         * The JWK set URL.
162         */
163        private final URL jwkSetURL;
164        
165        
166        /**
167         * Optional failover JWK source.
168         */
169        private final JWKSource<C> failoverJWKSource;
170        
171
172        /**
173         * The JWK set cache.
174         */
175        private final JWKSetCache jwkSetCache;
176
177
178        /**
179         * The JWK set retriever.
180         */
181        private final ResourceRetriever jwkSetRetriever;
182
183
184        /**
185         * Creates a new remote JWK set using the
186         * {@link DefaultResourceRetriever default HTTP resource retriever}
187         * with the default HTTP timeouts and entity size limit.
188         *
189         * @param jwkSetURL The JWK set URL. Must not be {@code null}.
190         */
191        public RemoteJWKSet(final URL jwkSetURL) {
192                this(jwkSetURL, (JWKSource<C>) null);
193        }
194
195
196        /**
197         * Creates a new remote JWK set using the
198         * {@link DefaultResourceRetriever default HTTP resource retriever}
199         * with the default HTTP timeouts and entity size limit.
200         *
201         * @param jwkSetURL         The JWK set URL. Must not be {@code null}.
202         * @param failoverJWKSource Optional failover JWK source in case
203         *                          retrieval from the JWK set URL fails,
204         *                          {@code null} if no failover is specified.
205         */
206        public RemoteJWKSet(final URL jwkSetURL, final JWKSource<C> failoverJWKSource) {
207                this(jwkSetURL, failoverJWKSource, null, null);
208        }
209
210
211        /**
212         * Creates a new remote JWK set.
213         *
214         * @param jwkSetURL         The JWK set URL. Must not be {@code null}.
215         * @param resourceRetriever The HTTP resource retriever to use,
216         *                          {@code null} to use the
217         *                          {@link DefaultResourceRetriever default
218         *                          one} with the default HTTP timeouts and
219         *                          entity size limit.
220         */
221        public RemoteJWKSet(final URL jwkSetURL,
222                            final ResourceRetriever resourceRetriever) {
223                
224                this(jwkSetURL, resourceRetriever, null);
225        }
226
227
228        /**
229         * Creates a new remote JWK set.
230         *
231         * @param jwkSetURL         The JWK set URL. Must not be {@code null}.
232         * @param resourceRetriever The HTTP resource retriever to use,
233         *                          {@code null} to use the
234         *                          {@link DefaultResourceRetriever default
235         *                          one} with the default HTTP timeouts and
236         *                          entity size limit.
237         * @param jwkSetCache       The JWK set cache to use, {@code null} to
238         *                          use the {@link DefaultJWKSetCache default
239         *                          one}.
240         */
241        public RemoteJWKSet(final URL jwkSetURL,
242                            final ResourceRetriever resourceRetriever,
243                            final JWKSetCache jwkSetCache) {
244                
245                this(jwkSetURL, null, resourceRetriever, jwkSetCache);
246        }
247
248
249        /**
250         * Creates a new remote JWK set.
251         *
252         * @param jwkSetURL         The JWK set URL. Must not be {@code null}.
253         * @param failoverJWKSource Optional failover JWK source in case
254         *                          retrieval from the JWK set URL fails,
255         *                          {@code null} if no failover is specified.
256         * @param resourceRetriever The HTTP resource retriever to use,
257         *                          {@code null} to use the
258         *                          {@link DefaultResourceRetriever default
259         *                          one} with the default HTTP timeouts and
260         *                          entity size limit.
261         * @param jwkSetCache       The JWK set cache to use, {@code null} to
262         *                          use the {@link DefaultJWKSetCache default
263         *                          one}.
264         */
265        public RemoteJWKSet(final URL jwkSetURL,
266                            final JWKSource<C> failoverJWKSource,
267                            final ResourceRetriever resourceRetriever,
268                            final JWKSetCache jwkSetCache) {
269                
270                if (jwkSetURL == null) {
271                        throw new IllegalArgumentException("The JWK set URL must not be null");
272                }
273                this.jwkSetURL = jwkSetURL;
274                
275                this.failoverJWKSource = failoverJWKSource;
276
277                if (resourceRetriever != null) {
278                        jwkSetRetriever = resourceRetriever;
279                } else {
280                        jwkSetRetriever = new DefaultResourceRetriever(
281                                resolveDefaultHTTPConnectTimeout(),
282                                resolveDefaultHTTPReadTimeout(),
283                                resolveDefaultHTTPSizeLimit());
284                }
285                
286                if (jwkSetCache != null) {
287                        this.jwkSetCache = jwkSetCache;
288                } else {
289                        this.jwkSetCache = new DefaultJWKSetCache();
290                }
291        }
292
293
294        /**
295         * Updates the cached JWK set from the configured URL.
296         *
297         * @return The updated JWK set.
298         *
299         * @throws RemoteKeySourceException If JWK retrieval failed.
300         */
301        private JWKSet updateJWKSetFromURL()
302                throws RemoteKeySourceException {
303                Resource res;
304                try {
305                        res = jwkSetRetriever.retrieveResource(jwkSetURL);
306                } catch (IOException e) {
307                        throw new RemoteKeySourceException("Couldn't retrieve remote JWK set: " + e.getMessage(), e);
308                }
309                JWKSet jwkSet;
310                try {
311                        jwkSet = JWKSet.parse(res.getContent());
312                } catch (java.text.ParseException e) {
313                        throw new RemoteKeySourceException("Couldn't parse remote JWK set: " + e.getMessage(), e);
314                }
315                jwkSetCache.put(jwkSet);
316                return jwkSet;
317        }
318
319
320        /**
321         * Returns the JWK set URL.
322         *
323         * @return The JWK set URL.
324         */
325        public URL getJWKSetURL() {
326                
327                return jwkSetURL;
328        }
329        
330        
331        /**
332         * Returns the optional failover JWK source.
333         *
334         * @return The failover JWK source, {@code null} if not specified.
335         */
336        public JWKSource<C> getFailoverJWKSource() {
337                
338                return failoverJWKSource;
339        }
340        
341        
342        /**
343         * Returns the HTTP resource retriever.
344         *
345         * @return The HTTP resource retriever.
346         */
347        public ResourceRetriever getResourceRetriever() {
348
349                return jwkSetRetriever;
350        }
351        
352        
353        /**
354         * Returns the configured JWK set cache.
355         *
356         * @return The JWK set cache.
357         */
358        public JWKSetCache getJWKSetCache() {
359                
360                return jwkSetCache;
361        }
362        
363        
364        /**
365         * Returns the cached JWK set.
366         *
367         * @return The cached JWK set, {@code null} if none or expired.
368         */
369        public JWKSet getCachedJWKSet() {
370                
371                return jwkSetCache.get();
372        }
373
374
375        /**
376         * Returns the first specified key ID (kid) for a JWK matcher.
377         *
378         * @param jwkMatcher The JWK matcher. Must not be {@code null}.
379         *
380         * @return The first key ID, {@code null} if none.
381         */
382        protected static String getFirstSpecifiedKeyID(final JWKMatcher jwkMatcher) {
383
384                Set<String> keyIDs = jwkMatcher.getKeyIDs();
385
386                if (keyIDs == null || keyIDs.isEmpty()) {
387                        return null;
388                }
389
390                for (String id: keyIDs) {
391                        if (id != null) {
392                                return id;
393                        }
394                }
395                return null; // No kid in matcher
396        }
397        
398        
399        /**
400         * Fails over to the configuration optional JWK source.
401         */
402        private List<JWK> failover(final Exception exception, final JWKSelector jwkSelector, final C context)
403                throws RemoteKeySourceException{
404                
405                if (getFailoverJWKSource() == null) {
406                        return null;
407                }
408                
409                try {
410                        return getFailoverJWKSource().get(jwkSelector, context);
411                } catch (KeySourceException kse) {
412                        throw new RemoteKeySourceException(
413                                exception.getMessage() +
414                                "; Failover JWK source retrieval failed with: " + kse.getMessage(),
415                                kse
416                        );
417                }
418        }
419        
420        
421        @Override
422        public List<JWK> get(final JWKSelector jwkSelector, final C context)
423                throws RemoteKeySourceException {
424
425                // Check the cache first
426                JWKSet jwkSet = jwkSetCache.get();
427                
428                if (jwkSetCache.requiresRefresh() || jwkSet == null) {
429                        // JWK set update required
430                        try {
431                                // Prevent multiple cache updates in case of concurrent requests
432                                // (with double-checked locking, i.e. locking on update required only)
433                                synchronized (this) {
434                                        jwkSet = jwkSetCache.get();
435                                        if (jwkSetCache.requiresRefresh() || jwkSet == null) {
436                                                // Retrieve JWK set from URL
437                                                jwkSet = updateJWKSetFromURL();
438                                        }
439                                }
440                        } catch (Exception e) {
441                                
442                                List<JWK> failoverMatches = failover(e, jwkSelector, context);
443                                if (failoverMatches != null) {
444                                        return failoverMatches; // Failover success
445                                }
446                                
447                                if (jwkSet == null) {
448                                        // Rethrow the received exception if expired
449                                        throw e;
450                                }
451                                
452                                // Continue with cached version
453                        }
454                }
455
456                // Run the selector on the JWK set
457                List<JWK> matches = jwkSelector.select(jwkSet);
458
459                if (! matches.isEmpty()) {
460                        // Success
461                        return matches;
462                }
463
464                // Refresh the JWK set if the sought key ID is not in the cached JWK set
465
466                // Looking for JWK with specific ID?
467                String soughtKeyID = getFirstSpecifiedKeyID(jwkSelector.getMatcher());
468                if (soughtKeyID == null) {
469                        // No key ID specified, return no matches
470                        return Collections.emptyList();
471                }
472
473                if (jwkSet.getKeyByKeyId(soughtKeyID) != null) {
474                        // The key ID exists in the cached JWK set, matching
475                        // failed for some other reason, return no matches
476                        return Collections.emptyList();
477                }
478                
479                try {
480                        // If the jwkSet in the cache is not the same instance that was
481                        // in the cache at the beginning of this method, then we know
482                        // the cache was updated
483                        synchronized (this) {
484                                if (jwkSet == jwkSetCache.get()) {
485                                        // Make new HTTP GET to the JWK set URL
486                                        jwkSet = updateJWKSetFromURL();
487                                } else {
488                                        // Cache was updated recently, the cached value is up-to-date
489                                        jwkSet = jwkSetCache.get();
490                                }
491                        }
492                } catch (KeySourceException e) {
493                        
494                        List<JWK> failoverMatches = failover(e, jwkSelector, context);
495                        if (failoverMatches != null) {
496                                return failoverMatches; // Failover success
497                        }
498                        
499                        throw e;
500                }
501                
502                
503                if (jwkSet == null) {
504                        // Retrieval has failed
505                        return Collections.emptyList();
506                }
507
508                // Repeat select, return final result (success or no matches)
509                return jwkSelector.select(jwkSet);
510        }
511}