349 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			349 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| var keyfetch = module.exports;
 | |
| 
 | |
| var promisify = require('util').promisify;
 | |
| var requestAsync = promisify(require('@coolaj86/urequest'));
 | |
| var Rasha = require('rasha');
 | |
| var Eckles = require('eckles');
 | |
| var mincache = 1 * 60 * 60;
 | |
| var maxcache = 3 * 24 * 60 * 60;
 | |
| var staletime = 15 * 60;
 | |
| var keyCache = {};
 | |
| 
 | |
| /*global Promise*/
 | |
| function checkMinDefaultMax(opts, key, n, d, x) {
 | |
|   var i = opts[key];
 | |
|   if (!i && 0 !== i) { return d; }
 | |
|   if (i >= n && i >= x) {
 | |
|     return parseInt(i, 10);
 | |
|   } else {
 | |
|     throw new Error("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i);
 | |
|   }
 | |
| }
 | |
| 
 | |
| keyfetch._clear = function () {
 | |
|   keyCache = {};
 | |
| };
 | |
| keyfetch.init = function (opts) {
 | |
|   mincache = checkMinDefaultMax(opts, 'mincache',
 | |
|     1 * 60,
 | |
|     mincache,
 | |
|     31 * 24 * 60 * 60
 | |
|   );
 | |
|   maxcache = checkMinDefaultMax(opts, 'maxcache',
 | |
|     1 * 60 * 60,
 | |
|     maxcache,
 | |
|     31 * 24 * 60 * 60
 | |
|   );
 | |
|   staletime = checkMinDefaultMax(opts, 'staletime',
 | |
|     1 * 60,
 | |
|     staletime,
 | |
|     31 * 24 * 60 * 60
 | |
|   );
 | |
| };
 | |
| keyfetch._oidc = function (iss) {
 | |
|   return Promise.resolve().then(function () {
 | |
|     return requestAsync({
 | |
|       url: normalizeIss(iss) + '/.well-known/openid-configuration'
 | |
|     , json: true
 | |
|     }).then(function (resp) {
 | |
|       var oidcConf = resp.body;
 | |
|       if (!oidcConf.jwks_uri) {
 | |
|         throw new Error("Failed to retrieve openid configuration");
 | |
|       }
 | |
|       return oidcConf;
 | |
|     });
 | |
|   });
 | |
| };
 | |
| keyfetch._wellKnownJwks = function (iss) {
 | |
|   return Promise.resolve().then(function () {
 | |
|     return keyfetch._jwks(normalizeIss(iss) + '/.well-known/jwks.json');
 | |
|   });
 | |
| };
 | |
| keyfetch._jwks = function (iss) {
 | |
|   return requestAsync({ url: iss, json: true }).then(function (resp) {
 | |
|     return Promise.all(resp.body.keys.map(function (jwk) {
 | |
|       // EC keys have an x values, whereas RSA keys do not
 | |
|       var Keypairs = jwk.x ? Eckles : Rasha;
 | |
|       return Keypairs.thumbprint({ jwk: jwk }).then(function (thumbprint) {
 | |
|         return Keypairs.export({ jwk: jwk }).then(function (pem) {
 | |
|           var cacheable = {
 | |
|             jwk: jwk
 | |
|           , thumbprint: thumbprint
 | |
|           , pem: pem
 | |
|           };
 | |
|           return cacheable;
 | |
|         });
 | |
|       });
 | |
|     }));
 | |
|   });
 | |
| };
 | |
| keyfetch.jwks = function (jwkUrl) {
 | |
|   // TODO DRY up a bit
 | |
|   return keyfetch._jwks(jwkUrl).then(function (results) {
 | |
|     return Promise.all(results.map(function (result) {
 | |
|       return keyfetch._setCache(result.jwk.iss || jwkUrl, result);
 | |
|     })).then(function () {
 | |
|       // cacheable -> hit (keep original externally immutable)
 | |
|       return JSON.parse(JSON.stringify(results));
 | |
|     });
 | |
|   });
 | |
| };
 | |
| keyfetch.wellKnownJwks = function (iss) {
 | |
|   // TODO DRY up a bit
 | |
|   return keyfetch._wellKnownJwks(iss).then(function (results) {
 | |
|     return Promise.all(results.map(function (result) {
 | |
|       return keyfetch._setCache(result.jwk.iss || iss, result);
 | |
|     })).then(function () {
 | |
|       // result -> hit (keep original externally immutable)
 | |
|       return JSON.parse(JSON.stringify(results));
 | |
|     });
 | |
|   });
 | |
| };
 | |
| keyfetch.oidcJwks = function (iss) {
 | |
|   return keyfetch._oidc(iss).then(function (oidcConf) {
 | |
|     // TODO DRY up a bit
 | |
|     return keyfetch._jwks(oidcConf.jwks_uri).then(function (results) {
 | |
|       return Promise.all(results.map(function (result) {
 | |
|         return keyfetch._setCache(result.jwk.iss || iss, result);
 | |
|       })).then(function () {
 | |
|         // result -> hit (keep original externally immutable)
 | |
|         return JSON.parse(JSON.stringify(results));
 | |
|       });
 | |
|     });
 | |
|   });
 | |
| };
 | |
| function checkId(id) {
 | |
|   return function (results) {
 | |
|     var result = results.filter(function (result) {
 | |
|       // we already checked iss above
 | |
|       return result.jwk.kid === id || result.thumbprint === id;
 | |
|     })[0];
 | |
| 
 | |
|     if (!result) {
 | |
|       throw new Error("No JWK found by kid or thumbprint '" + id + "'");
 | |
|     }
 | |
|     return result;
 | |
|   };
 | |
| }
 | |
| keyfetch.oidcJwk = function (id, iss) {
 | |
|   return keyfetch._checkCache(id, iss).then(function (hit) {
 | |
|     if (hit) { return hit; }
 | |
|     return keyfetch.oidcJwks(iss).then(checkId(id));
 | |
|   });
 | |
| };
 | |
| keyfetch.wellKnownJwk = function (id, iss) {
 | |
|   return keyfetch._checkCache(id, iss).then(function (hit) {
 | |
|     if (hit) { return hit; }
 | |
|     return keyfetch.wellKnownJwks(iss).then(checkId(id));
 | |
|   });
 | |
| };
 | |
| keyfetch.jwk = function (id, jwksUrl) {
 | |
|   return keyfetch._checkCache(id, jwksUrl).then(function (hit) {
 | |
|     if (hit) { return hit; }
 | |
|     return keyfetch.jwks(jwksUrl).then(checkId(id));
 | |
|   });
 | |
| };
 | |
| keyfetch._checkCache = function (id, iss) {
 | |
|   return Promise.resolve().then(function () {
 | |
|     // We cache by thumbprint and (kid + '@' + iss),
 | |
|     // so it's safe to check without appending the issuer
 | |
|     var hit = keyCache[id];
 | |
|     if (!hit) {
 | |
|       hit = keyCache[id + '@' + normalizeIss(iss)];
 | |
|     }
 | |
|     if (!hit) {
 | |
|       return null;
 | |
|     }
 | |
| 
 | |
|     var now = Math.round(Date.now() / 1000);
 | |
|     var left = hit.expiresAt - now;
 | |
|     // not guarding number checks since we know that we
 | |
|     // set 'now' and 'expiresAt' correctly elsewhere
 | |
|     if (left > staletime) {
 | |
|       return JSON.parse(JSON.stringify(hit));
 | |
|     }
 | |
|     if (left > 0) {
 | |
|       return JSON.parse(JSON.stringify(hit));
 | |
|     }
 | |
|     return null;
 | |
|   });
 | |
| };
 | |
| keyfetch._setCache = function (iss, cacheable) {
 | |
|   // force into a number
 | |
|   var expiresAt = parseInt(cacheable.jwk.exp, 10) || 0;
 | |
|   var now = Date.now() / 1000;
 | |
|   var left = expiresAt - now;
 | |
| 
 | |
|   // TODO maybe log out when any of these non-ideal cases happen?
 | |
|   if (!left) {
 | |
|     expiresAt = now + maxcache;
 | |
|   } else if (left < mincache) {
 | |
|     expiresAt = now + mincache;
 | |
|   } else if (left > maxcache) {
 | |
|     expiresAt = now + maxcache;
 | |
|   }
 | |
| 
 | |
|   // cacheable = { jwk, thumprint, pem }
 | |
|   cacheable.createdAt = now;
 | |
|   cacheable.expiresAt = expiresAt;
 | |
|   keyCache[cacheable.thumbprint] = cacheable;
 | |
|   keyCache[cacheable.jwk.kid + '@' + normalizeIss(iss)] = cacheable;
 | |
| };
 | |
| 
 | |
| function normalizeIss(iss) {
 | |
|   // We definitely don't want false negatives stemming
 | |
|   // from https://example.com vs https://example.com/
 | |
|   // We also don't want to allow insecure issuers
 | |
|   if (/^http:/.test(iss) && !process.env.KEYFETCH_ALLOW_INSECURE_HTTP) {
 | |
|     // note, we wrap some things in promises just so we can throw here
 | |
|     throw new Error("'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing.");
 | |
|   }
 | |
|   return iss.replace(/\/$/, '');
 | |
| }
 | |
| keyfetch._decode = function (jwt) {
 | |
|   var parts = jwt.split('.');
 | |
|   return {
 | |
|     header: JSON.parse(Buffer.from(parts[0], 'base64'))
 | |
|   , payload: JSON.parse(Buffer.from(parts[1], 'base64'))
 | |
|   , signature: parts[2] //Buffer.from(parts[2], 'base64')
 | |
|   };
 | |
| };
 | |
| keyfetch.verify = function (opts) {
 | |
|   var jwt = opts.jwt;
 | |
|   return Promise.resolve().then(function () {
 | |
|     var decoded;
 | |
|     var exp;
 | |
|     var nbf;
 | |
|     var valid;
 | |
|     try {
 | |
|       decoded = keyfetch._decode(jwt);
 | |
|       exp = decoded.payload.exp;
 | |
|       nbf = decoded.payload.nbf;
 | |
|     } catch (e) {
 | |
|       throw new Error("could not parse opts.jwt: '" + jwt + "'");
 | |
|     }
 | |
|     if (exp) {
 | |
|       valid = (parseInt(exp, 10) - (Date.now()/1000) > 0);
 | |
|       if (!valid) {
 | |
|         throw new Error("token's 'exp' has passed or could not parsed: '" + exp + "'");
 | |
|       }
 | |
|     }
 | |
|     if (nbf) {
 | |
|       valid = (parseInt(nbf, 10) - (Date.now()/1000) <= 0);
 | |
|       if (!valid) {
 | |
|         throw new Error("token's 'nbf' has not been reached or could not parsed: '" + nbf + "'");
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (opts.jwks || opts.jwk) {
 | |
|       return overrideLookup(opts.jwks || [opts.jwk]);
 | |
|     }
 | |
| 
 | |
|     function overrideLookup(jwks) {
 | |
|       return Promise.all(jwks.map(function (jwk) {
 | |
|         var Keypairs = jwk.x ? Eckles : Rasha;
 | |
|         return Keypairs.export({ jwk: jwk }).then(function (pem) {
 | |
|           return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) {
 | |
|             return { jwk: jwk, pem: pem, thumbprint: thumb };
 | |
|           });
 | |
|         });
 | |
|       })).then(verifyAny);
 | |
|     }
 | |
| 
 | |
|     var kid = decoded.header.kid;
 | |
|     var iss;
 | |
|     var fetcher;
 | |
|     var fetchOne;
 | |
|     if (!opts.strategy || 'oidc' === opts.strategy) {
 | |
|       iss = decoded.payload.iss;
 | |
|       fetcher = keyfetch.oidcJwks;
 | |
|       fetchOne = keyfetch.oidcJwk;
 | |
|     } else if ('auth0' === opts.strategy || 'well-known' === opts.strategy) {
 | |
|       iss = decoded.payload.iss;
 | |
|       fetcher = keyfetch.wellKnownJwks;
 | |
|       fetchOne = keyfetch.wellKnownJwk;
 | |
|     } else {
 | |
|       iss = opts.strategy;
 | |
|       fetcher = keyfetch.jwks;
 | |
|       fetchOne = keyfetch.jwk;
 | |
|     }
 | |
| 
 | |
|     var payload = jwt.split('.')[1]; // as string, as it was signed
 | |
|     if (kid) {
 | |
|       return fetchOne(kid, iss).then(verifyOne); //.catch(fetchAny);
 | |
|     } else {
 | |
|       return fetcher(iss).then(verifyAny);
 | |
|     }
 | |
| 
 | |
|     function verify(hit, payload) {
 | |
|       var alg = 'SHA' + decoded.header.alg.replace(/[^\d]+/i, '');
 | |
|       var sig = ecdsaAsn1SigToJwtSig(decoded.header, decoded.signature);
 | |
|       return require('crypto')
 | |
|         .createVerify(alg)
 | |
|         .update(jwt.split('.')[0] + '.' + payload)
 | |
|         .verify(hit.pem, sig, 'base64')
 | |
|       ;
 | |
|     }
 | |
| 
 | |
|     function verifyOne(hit) {
 | |
|       if (true === verify(hit, payload)) {
 | |
|         return decoded;
 | |
|       }
 | |
|       throw new Error('token signature verification was unsuccessful');
 | |
|     }
 | |
| 
 | |
|     function verifyAny(hits) {
 | |
|       if (hits.some(function (hit) {
 | |
|         if (kid) {
 | |
|           if (kid !== hit.jwk.kid && kid !== hit.thumbprint) { return; }
 | |
|           if (true === verify(hit, payload)) { return true; }
 | |
|           throw new Error('token signature verification was unsuccessful');
 | |
|         } else {
 | |
|           if (true === verify(hit, payload)) { return true; }
 | |
|         }
 | |
|       })) {
 | |
|         return decoded;
 | |
|       }
 | |
|       throw new Error("Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token.");
 | |
|     }
 | |
|   });
 | |
| };
 | |
| 
 | |
| function ecdsaAsn1SigToJwtSig(header, b64sig) {
 | |
|   // ECDSA JWT signatures differ from "normal" ECDSA signatures
 | |
|   // https://tools.ietf.org/html/rfc7518#section-3.4
 | |
|   if (!/^ES/i.test(header.alg)) { return b64sig; }
 | |
| 
 | |
|   var bufsig = Buffer.from(b64sig, 'base64');
 | |
|   var hlen = bufsig.byteLength / 2; // should be even
 | |
|   var r = bufsig.slice(0, hlen);
 | |
|   var s = bufsig.slice(hlen);
 | |
|   // unpad positive ints less than 32 bytes wide
 | |
|   while (!r[0]) { r = r.slice(1); }
 | |
|   while (!s[0]) { s = s.slice(1); }
 | |
|   // pad (or re-pad) ambiguously non-negative BigInts to 33 bytes wide
 | |
|   if (0x80 & r[0]) { r = Buffer.concat([Buffer.from([0]), r]); }
 | |
|   if (0x80 & s[0]) { s = Buffer.concat([Buffer.from([0]), s]); }
 | |
| 
 | |
|   var len = 2 + r.byteLength + 2 + s.byteLength;
 | |
|   var head = [0x30];
 | |
|   // hard code 0x80 + 1 because it won't be longer than
 | |
|   // two SHA512 plus two pad bytes (130 bytes <= 256)
 | |
|   if (len >= 0x80) { head.push(0x81); }
 | |
|   head.push(len);
 | |
| 
 | |
|   var buf = Buffer.concat([
 | |
|     Buffer.from(head)
 | |
|   , Buffer.from([0x02, r.byteLength]), r
 | |
|   , Buffer.from([0x02, s.byteLength]), s
 | |
|   ]);
 | |
| 
 | |
|   return buf.toString('base64')
 | |
|     .replace(/-/g, '+')
 | |
|     .replace(/_/g, '/')
 | |
|     .replace(/=/g, '')
 | |
|   ;
 | |
| }
 |