keyfetch.js/keyfetch.js

447 lines
14 KiB
JavaScript

"use strict";
var keyfetch = module.exports;
var request = require("@root/request").defaults({
userAgent: "keyfetch/v2.1.0"
});
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 = {};
var Errors = require("./lib/errors.js");
async function requestAsync(req) {
var resp = await request(req).catch(Errors.BAD_GATEWAY);
// differentiate potentially temporary server errors from 404
if (!resp.ok && (resp.statusCode >= 500 || resp.statusCode < 200)) {
throw Errors.BAD_GATEWAY({ response: resp });
}
return resp;
}
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 Errors.DEVELOPER_ERROR("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i);
}
}
keyfetch._errors = Errors;
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 = async function (iss) {
var url = normalizeIss(iss) + "/.well-known/openid-configuration";
var resp = await requestAsync({
url: url,
json: true
});
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw Errors.NO_JWKS_URI(url);
}
return oidcConf;
};
keyfetch._wellKnownJwks = async function (iss) {
return keyfetch._jwks(normalizeIss(iss) + "/.well-known/jwks.json");
};
keyfetch._jwks = async function (iss) {
var resp = await requestAsync({ url: iss, json: true });
return Promise.all(
resp.body.keys.map(async function (jwk) {
// EC keys have an x values, whereas RSA keys do not
var Keypairs = jwk.x ? Eckles : Rasha;
var thumbprint = await Keypairs.thumbprint({ jwk: jwk });
var pem = await Keypairs.export({ jwk: jwk });
var cacheable = {
jwk: jwk,
thumbprint: thumbprint,
pem: pem
};
return cacheable;
})
);
};
keyfetch.jwks = async function (jwkUrl) {
// TODO DRY up a bit
var results = await keyfetch._jwks(jwkUrl);
await Promise.all(
results.map(async function (result) {
return keyfetch._setCache(result.jwk.iss || jwkUrl, result);
})
);
// cacheable -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
};
keyfetch.wellKnownJwks = async function (iss) {
// TODO DRY up a bit
var results = await keyfetch._wellKnownJwks(iss);
await Promise.all(
results.map(async function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result);
})
);
// result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
};
keyfetch.oidcJwks = async function (iss) {
var oidcConf = await keyfetch._oidc(iss);
// TODO DRY up a bit
var results = await keyfetch._jwks(oidcConf.jwks_uri);
await Promise.all(
results.map(async function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result);
})
);
// 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 Errors.JWK_NOT_FOUND(id);
}
return result;
};
}
keyfetch.oidcJwk = async function (id, iss) {
var hit = await keyfetch._checkCache(id, iss);
if (hit) {
return hit;
}
return keyfetch.oidcJwks(iss).then(checkId(id));
};
keyfetch.wellKnownJwk = async function (id, iss) {
var hit = await keyfetch._checkCache(id, iss);
if (hit) {
return hit;
}
return keyfetch.wellKnownJwks(iss).then(checkId(id));
};
keyfetch.jwk = async function (id, jwksUrl) {
var hit = await keyfetch._checkCache(id, jwksUrl);
if (hit) {
return hit;
}
return keyfetch.jwks(jwksUrl).then(checkId(id));
};
keyfetch._checkCache = async function (id, iss) {
// 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) {
if (!iss) {
throw Errors.NO_ISSUER();
}
// 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 Errors.INSECURE_ISSUER(iss);
}
return iss.replace(/\/$/, "");
}
keyfetch.jwt = {};
keyfetch.jwt.decode = function (jwt) {
try {
var parts = jwt.split(".");
// JWS
var obj = { protected: parts[0], payload: parts[1], signature: parts[2] };
// JWT
obj.header = JSON.parse(Buffer.from(obj.protected, "base64"));
obj.claims = JSON.parse(Buffer.from(obj.payload, "base64"));
return obj;
} catch (e) {
var err = Errors.PARSE_ERROR(jwt);
err.details = e.message;
throw err;
}
};
keyfetch.jwt.verify = async function (jwt, opts) {
if (!opts) {
opts = {};
}
var jws;
var exp;
var nbf;
var active;
var now;
var then;
var issuers = opts.issuers || [];
if (opts.iss) {
issuers.push(opts.iss);
}
if (opts.claims && opts.claims.iss) {
issuers.push(opts.claims.iss);
}
if (!issuers.length) {
if (!(opts.jwk || opts.jwks)) {
throw Errors.DEVELOPER_ERROR(
"[keyfetch.js] Security Error: Neither of opts.issuers nor opts.iss were provided. If you would like to bypass issuer verification (i.e. for federated authn) you must explicitly set opts.issuers = ['*']. Otherwise set a value such as https://accounts.google.com/"
);
}
}
var claims = opts.claims || {};
if (!jwt || "string" === typeof jwt) {
jws = keyfetch.jwt.decode(jwt);
} else {
jws = jwt;
}
if (!jws.claims.iss || !issuers.some(isTrustedIssuer(jws.claims.iss))) {
if (!(opts.jwk || opts.jwks)) {
throw Errors.UNKNOWN_ISSUER(jws.claims.iss || "");
}
}
// Note claims.iss validates more strictly than opts.issuers (requires exact match)
var failedClaims = Object.keys(claims)
.filter(function (key) {
if (claims[key] !== jws.claims[key]) {
return true;
}
})
.map(function (key) {
return "jwt.claims." + key + " = " + JSON.stringify(jws.claims[key]);
});
if (failedClaims.length) {
throw Errors.FAILED_CLAIMS(failedClaims, Object.keys(claims));
}
exp = jws.claims.exp;
if (exp && false !== opts.exp) {
now = Date.now();
// TODO document that opts.exp can be used as leeway? Or introduce opts.leeway?
// fair, but not necessary
exp = parseInt(exp, 10);
if (isNaN(exp)) {
throw Errors.MALFORMED_EXP(JSON.stringify(jws.claims.exp));
}
then = (opts.exp || 0) + parseInt(exp, 10);
active = then - now / 1000 > 0;
// expiration was on the token or, if not, such a token is not allowed
if (!active) {
throw Errors.EXPIRED(exp);
}
}
nbf = jws.claims.nbf;
if (nbf) {
active = parseInt(nbf, 10) - Date.now() / 1000 <= 0;
if (!active) {
throw Errors.INACTIVE(nbf);
}
}
if (opts.jwks || opts.jwk) {
return overrideLookup(opts.jwks || [opts.jwk]);
}
var kid = jws.header.kid;
var iss;
var fetcher;
var fetchOne;
if (!opts.strategy || "oidc" === opts.strategy) {
iss = jws.claims.iss;
fetcher = keyfetch.oidcJwks;
fetchOne = keyfetch.oidcJwk;
} else if ("auth0" === opts.strategy || "well-known" === opts.strategy) {
iss = jws.claims.iss;
fetcher = keyfetch.wellKnownJwks;
fetchOne = keyfetch.wellKnownJwk;
} else {
iss = opts.strategy;
fetcher = keyfetch.jwks;
fetchOne = keyfetch.jwk;
}
if (kid) {
return fetchOne(kid, iss).then(verifyOne); //.catch(fetchAny);
}
return fetcher(iss).then(verifyAny);
function verifyOne(hit) {
if (true === keyfetch.jws.verify(jws, hit)) {
return jws;
}
throw Errors.BAD_SIGNATURE(jws.protected + "." + jws.payload + "." + jws.signature);
}
function verifyAny(hits) {
if (
hits.some(function (hit) {
if (kid) {
if (kid !== hit.jwk.kid && kid !== hit.thumbprint) {
return;
}
if (true === keyfetch.jws.verify(jws, hit)) {
return true;
}
throw Errors.BAD_SIGNATURE();
}
if (true === keyfetch.jws.verify(jws, hit)) {
return true;
}
})
) {
return jws;
}
throw Errors.JWK_NOT_FOUND_OLD(kid);
}
function overrideLookup(jwks) {
return Promise.all(
jwks.map(async function (jwk) {
var Keypairs = jwk.x ? Eckles : Rasha;
var pem = await Keypairs.export({ jwk: jwk });
var thumb = await Keypairs.thumbprint({ jwk: jwk });
return { jwk: jwk, pem: pem, thumbprint: thumb };
})
).then(verifyAny);
}
};
keyfetch.jws = {};
keyfetch.jws.verify = function (jws, pub) {
var alg = "SHA" + jws.header.alg.replace(/[^\d]+/i, "");
var sig = ecdsaJoseSigToAsn1Sig(jws.header, jws.signature);
return require("crypto")
.createVerify(alg)
.update(jws.protected + "." + jws.payload)
.verify(pub.pem, sig, "base64");
};
// old, gotta make sure nothing else uses this
keyfetch._decode = function (jwt) {
var obj = keyfetch.jwt.decode(jwt);
return { header: obj.header, payload: obj.claims, signature: obj.signature };
};
keyfetch.verify = async function (opts) {
var jwt = opts.jwt;
var obj = await keyfetch.jwt.verify(jwt, opts);
return { header: obj.header, payload: obj.claims, signature: obj.signature };
};
function ecdsaJoseSigToAsn1Sig(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, "");
}
function isTrustedIssuer(issuer) {
return function (trusted) {
if ("*" === trusted) {
return true;
}
// TODO account for '*.example.com'
trusted = /^http(s?):\/\//.test(trusted) ? trusted : "https://" + trusted;
return issuer.replace(/\/$/, "") === trusted.replace(/\/$/, "") && trusted;
};
}