2019-02-25 22:54:08 +00:00
|
|
|
'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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-02-25 23:17:26 +00:00
|
|
|
keyfetch._clear = function () {
|
|
|
|
keyCache = {};
|
|
|
|
};
|
2019-02-25 22:54:08 +00:00
|
|
|
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));
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
2019-02-25 22:58:02 +00:00
|
|
|
function checkId(id) {
|
|
|
|
return function (results) {
|
2019-02-25 23:17:26 +00:00
|
|
|
var result = results.filter(function (result) {
|
2019-02-25 22:58:02 +00:00
|
|
|
// 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;
|
|
|
|
};
|
|
|
|
}
|
2019-02-25 22:54:08 +00:00
|
|
|
keyfetch.oidcJwk = function (id, iss) {
|
|
|
|
return keyfetch._checkCache(id, iss).then(function (hit) {
|
2019-02-25 22:58:02 +00:00
|
|
|
if (hit) { return hit; }
|
|
|
|
return keyfetch.oidcJwks(iss).then(checkId(id));
|
2019-02-25 22:54:08 +00:00
|
|
|
});
|
|
|
|
};
|
|
|
|
keyfetch.wellKnownJwk = function (id, iss) {
|
|
|
|
return keyfetch._checkCache(id, iss).then(function (hit) {
|
2019-02-25 22:58:02 +00:00
|
|
|
if (hit) { return hit; }
|
|
|
|
return keyfetch.wellKnownJwks(iss).then(checkId(id));
|
2019-02-25 22:54:08 +00:00
|
|
|
});
|
|
|
|
};
|
|
|
|
keyfetch.jwk = function (id, jwksUrl) {
|
|
|
|
return keyfetch._checkCache(id, jwksUrl).then(function (hit) {
|
2019-02-25 22:58:02 +00:00
|
|
|
if (hit) { return hit; }
|
|
|
|
return keyfetch.jwks(jwksUrl).then(checkId(id));
|
2019-02-25 22:54:08 +00:00
|
|
|
});
|
|
|
|
};
|
|
|
|
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 + "'");
|
|
|
|
}
|
|
|
|
}
|
2019-03-09 09:50:14 +00:00
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2019-02-25 22:54:08 +00:00
|
|
|
var kid = decoded.header.kid;
|
|
|
|
var iss;
|
|
|
|
var fetcher;
|
2019-02-27 06:11:26 +00:00
|
|
|
var fetchOne;
|
2019-02-25 22:54:08 +00:00
|
|
|
if (!opts.strategy || 'oidc' === opts.strategy) {
|
|
|
|
iss = decoded.payload.iss;
|
|
|
|
fetcher = keyfetch.oidcJwks;
|
2019-02-27 06:11:26 +00:00
|
|
|
fetchOne = keyfetch.oidcJwk;
|
2019-02-25 22:54:08 +00:00
|
|
|
} else if ('auth0' === opts.strategy || 'well-known' === opts.strategy) {
|
|
|
|
iss = decoded.payload.iss;
|
|
|
|
fetcher = keyfetch.wellKnownJwks;
|
2019-02-27 06:11:26 +00:00
|
|
|
fetchOne = keyfetch.wellKnownJwk;
|
2019-02-25 22:54:08 +00:00
|
|
|
} else {
|
|
|
|
iss = opts.strategy;
|
|
|
|
fetcher = keyfetch.jwks;
|
2019-02-27 06:11:26 +00:00
|
|
|
fetchOne = keyfetch.jwk;
|
2019-02-25 22:54:08 +00:00
|
|
|
}
|
2019-03-09 01:00:41 +00:00
|
|
|
|
2019-03-09 09:50:14 +00:00
|
|
|
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) {
|
2019-03-09 01:00:41 +00:00
|
|
|
var alg = 'SHA' + decoded.header.alg.replace(/[^\d]+/i, '');
|
2019-03-09 09:50:14 +00:00
|
|
|
var sig = ecdsaAsn1SigToJwtSig(decoded.header, decoded.signature);
|
2019-02-25 22:54:08 +00:00
|
|
|
return require('crypto')
|
|
|
|
.createVerify(alg)
|
|
|
|
.update(jwt.split('.')[0] + '.' + payload)
|
2019-03-09 09:50:14 +00:00
|
|
|
.verify(hit.pem, sig, 'base64')
|
2019-03-09 02:15:24 +00:00
|
|
|
;
|
2019-02-25 22:54:08 +00:00
|
|
|
}
|
2019-03-09 01:00:41 +00:00
|
|
|
|
2019-03-09 09:50:14 +00:00
|
|
|
function verifyOne(hit) {
|
|
|
|
if (true === verify(hit, payload)) {
|
2019-03-09 01:00:41 +00:00
|
|
|
return decoded;
|
|
|
|
}
|
|
|
|
throw new Error('token signature verification was unsuccessful');
|
2019-02-27 06:11:26 +00:00
|
|
|
}
|
|
|
|
|
2019-03-09 09:50:14 +00:00
|
|
|
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; }
|
2019-02-25 22:54:08 +00:00
|
|
|
}
|
2019-03-09 09:50:14 +00:00
|
|
|
})) {
|
|
|
|
return decoded;
|
|
|
|
}
|
|
|
|
throw new Error("Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token.");
|
2019-02-27 06:11:26 +00:00
|
|
|
}
|
2019-02-25 22:54:08 +00:00
|
|
|
});
|
|
|
|
};
|
2019-03-09 09:50:14 +00:00
|
|
|
|
|
|
|
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, '')
|
|
|
|
;
|
|
|
|
}
|