jwks-utils
Version:
Utility methods for working with a JSON Web Key (JWK) and/or JSON Web Key Set (JWKs)
280 lines (247 loc) • 11.5 kB
JavaScript
/* Copyright 2014 Open Ag Data Alliance
*
* Licensed 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.
*/
;
var url = require('url');
var jws = require('jws');
var equal = require('deep-equal');
const debug = require('debug');
const trace = debug('node-jwks-utils:trace');
const info = debug('node-jwks-utils:info');
const warn = debug('node-jwks-utils:warn');
const request = require('superagent');
var utils = {};
//----------------------------------------------------------------------
// Caching jwks requests/responses:
let jwksCache = {};
const cacheStaleTimeoutSec = 3600; // 1 hour
const cacheFailureTimeout = 3600*24; // 24 hours: how long to use cached value if network request fails
const cacheMaxSizeMB = 20; // Maximum MB allowed in the jwks cache before pruning old ones
const cacheSize = () => Object.keys(jwksCache).reduce((acc,uri) => (acc+jwksCache[uri].strbytes), 0);
const cachePruneOldest = () => {
const oldest = Object.keys(jwksCache).reduce((acc,uri) => {
if (!acc) return uri; // first one
const curold = jwksCache[acc.uri];
if (jwksCache[uri].timePutIntoCache < curold.timePutIntoCache) {
return uri; // this uri is now min
}
return acc; // previous min is still min
}, false);
if (!oldest) {
// nothing in the cache)
return false;
}
delete jwksCache[oldest];
return true;
}
const putInCache = (uri,jwks,strbytes) => {
if (strbytes/1000000 > cacheMaxSizeMB) {
warn('WARNING: refusing to cache jwks from uri '+uri+' because it\'s size alone ('+strbytes+') is larger than cacheMaxSizeMB ('+cacheMaxSizeMB+')');
return false;
}
while (cacheSize() + strbytes > cacheMaxSizeMB) {
if (!cachePruneOldest()) break; // if pruneOldest fails, stop looping
}
if (jwksCache[uri]) trace('Putting uri ',uri,' into cache with new timestamp, replacing previous entry')
jwksCache[uri] = {
timePutIntoCache: Date.now()/1000,
jwks,
};
return true;
}
const cachePruneIfFailureTimeout = uri => {
const now = Date.now() / 1000;
if (jwksCache[uri] && (now - jwksCache[uri].timePutIntoCache) > cacheFailureTimeout) {
info('jku request failed for uri ', uri, ', and it has been longer than cacheFailureTimeout, so removing that uri from cache due to failure');
// remove from cache
delete jwksCache[uri];
}
}
const cacheHasURIThatIsNotStale = uri => {
const now = Date.now() / 1000;
return (jwksCache[uri] && (now - jwksCache[uri].timePutIntoCache) < cacheStaleTimeoutSec);
}
//-------------------------------------------------
// Primary exported module:
// Exporting some cache functions for testing:
utils.clearJWKsCache = function() { jwksCache = {}; }
utils.getJWKsCache = function() { return jwksCache; }
utils.cachePruneOldest = cachePruneOldest;
// Decide if an object is a JWK
utils.isJWK = function isJWK(key) {
return !!(key && key.kty);
};
// Decide if an object is a set of JWKs
utils.isJWKset = function isJWKset(set) {
const keys = set && set.keys;
return !!keys && typeof keys.some === 'function' && keys.some(utils.isJWK);
};
// Pick a JWK from a JWK set by its Key ID
utils.findJWK = function findJWK(kid, jwks) {
if (!kid) return undefined;
let res = undefined;
jwks.keys.every(jwk => {
if (utils.isJWK(jwk) && (jwk.kid === kid)) {
res = jwk;
return false;
}
return true;
});
return res;
};
// Maybe move to a JWS library?
// Supported headers: [kid, jwk, jku]
utils.jwkForSignature = function jwkForSignature(sig, hint, options, callback) {
if (typeof options === 'function') {
callback = options;
options = undefined;
}
options = options || {};
const jose = jws.decode(sig).header;
// The only place the callback is allowed to be called is inside this
// checkJWKEqualsJoseJWK. That way it's easier to manage the two threads
// to make sure we don't call the callback twice.
let alreadyCalledCallback = false;
const checkJWKEqualsJoseJWK = (err, jwk) => {
if (jose.jwk && !equal(jwk, jose.jwk, {strict: true})) {
err = err || new Error('JWK did not match jwk JOSE header');
}
if (!alreadyCalledCallback) {
alreadyCalledCallback = true;
callback(err, jwk);
}
}
// Retieve JWKS from a JKU URI
// Update for 1.0.6: Adding an in-memory cache that will both
// 1: speedup requests with jku's, and
// 2: keep working with cached copy for 24 hours in the event of network outage.
// The cache will respond immediately with a cached value if the cached value is less than
// 1 hour old and the kid is in it. It will always go get the latest jwks at the URL to
// update the cache, but if the kid is already in the cached copy it will return that immediately
// and not wait on the request to finish. If the kid is not found in the cached value, it will
// wait for the request to finish and then look for the kid in the new copy. In this way, if a
// new kid is published in your jwks, it will be immediately available to all clients. If a key
// is deemed no longer trusted and removed from the jwks, then it will only validate at most one
// time in at most a 1 hour window after un-publishing.
const getJWK = uri => {
// MUST use HTTPS (not HTTP)
const u = url.parse(uri);
u.protocol = 'https';
uri = url.format(u);
const req = request.get(uri);
if (typeof req.buffer === 'function') req.buffer();
req.timeout(options.timeout || 1000);
// Fire off the request here first, then immediately check cache before javascript event queue moves on.
// If it's there, then that "thread" of execution will call the callback instead of the one after
// the request finishes. If it wasn't there, then the request's callback here will call it.
trace('Sending out GET request for uri '+uri+', will check cache while it is waiting');
const promiseToWaitOnRequest = req.end(function recieveJWKSet(err, resp) {
let e = err || resp.error;
let jwks;
// If there was no error, then we can go ahead and try to parse the body (which could result in an error)
if (!e) {
trace('Finished retrieving uri ',uri,', had no error in the request, will now try to parse response.');
try {
jwks = JSON.parse(resp.text);
if (!utils.isJWKset(jwks)) {
throw new Error('jwks parsed successfully with JSON.parse, but it was not a valid jwks');
}
// Put this successful jwks set into the cache
if (putInCache(uri, jwks, resp.text.length)) {
trace('Added jwks to cache for uri '+uri);
} else {
info('Failed to add jwks to cache for uri '+uri);
}
} catch (err) {
e = err;
warn('WARNING: failed to read jwks response from jku uri '+uri+', error was: ' + e.toString());
}
}
// If we get to this point, either jwks is valid and in the jwks variable, or we had an error
if (e) {
warn('jku request failed for uri ', uri);
// If the request had an error (i.e. network or host is down now), let's check if we have
// the jwks in the cache. If we do, we'll go ahead and use it for up to 24 hours before
// removing it due to the failure.
cachePruneIfFailureTimeout(uri);
// Now if it's not in the cache, since the request had an error, then return the error
if (!jwksCache[uri]) return checkJWKEqualsJoseJWK(e);
// If we get here, there was an error, but it was in the cache still before the cacheFailureTimeout,
// so put that in the main jwks variable to check later
info('jku request failed for uri ',uri,', but we have cached copy that we will use for 24 hours');
jwks = jwksCache[uri].jwks;
}
// And finally, if we got to this point, we either did not have an error, or we had an error but
// we decided to use our cached value. Either way, the jwks variable now has a valid jwks in it.
// This ends the thread that runs after the web request finishes.
return checkJWKEqualsJoseJWK(null, utils.findJWK(jose.kid, jwks));
});
// Now, check if we already have the uri in the cache and
// if the kid is already in it's list of keys:
trace('Checking cache for non-stale uri ', uri);
if (cacheHasURIThatIsNotStale(uri)) {
trace('Found uri ',uri,' in cache and it is not stale, returning it immediately');
const jwk = utils.findJWK(jose.kid, jwksCache[uri].jwks);
if (jwk) return checkJWKEqualsJoseJWK(null, jwk);
}
trace('Did not find non-stale uri ',uri,' in cache, waiting on request to complete instead');
// If we get here, then we did not have a valid, un-stale kid in the cache, so we need
// to wait on the request to call the callback instead (above). If it fails above, then it
// will continue to use the stale URI until the 24-hour failure period. The callback for the
// overall function will end up being called in the callback for the request.
return;
}
// Now we can do the main part of the function which checks the hint and then calls one of
// the functions above....
// This hint thing is complicated....
// It was designed to make it simple to say something like:
// "hint: I looked up the JKU ot JWK on the signature, and it was from a trusted source."
// (i.e. hint = truthy),
// and it's truthy value is then either the JWKS (object) from the trusted source,
// or the jku (string) of the trusted source's jwks
// or
// "hint: I looked at my trusted sources and this one doesn't have a jwk or jku that matches."
// (i.e. hint === false)
// which means "just use the header on the signature because I have no outside reference that verifies it"
//
// - If boolean false, use the jku from the jose header and if no jku then use jose's jwk
// - If boolean true, throw error (it should have been either an object or a string)
// - If string, assume string is a jku uri, go get that URI and then check jose's jwk against it
// - If object and looks like a jwks, look for jose's jwk in the set
// - If object and looks like a jwk, compare that jwk with jose's jwk
switch (typeof hint) {
case 'boolean':
if (hint === false) {
// Lookup soley based on JOSE headers
if (jose.jku) return getJWK(jose.jku);
// If no jku uri, then just use the jwk on the jose header as last resort
return checkJWKEqualsJoseJWK(null, jose.jwk);
}
break;
case 'string':
return getJWK(hint);
break;
case 'object':
if (utils.isJWKset(hint)) {
return checkJWKEqualsJoseJWK(null, utils.findJWK(jose.kid, hint));
}
if (utils.isJWK(hint) && jose.kid === hint.kid) {
return checkJWKEqualsJoseJWK(null, hint);
}
break;
}
// If we get here, the hint didn't make sense so we error out:
return checkJWKEqualsJoseJWK(new Error('Invalid hint'));
};
module.exports = utils;