feature: add distinct error codes

This commit is contained in:
AJ ONeal 2021-10-20 18:12:06 -06:00
parent 7d5889b4de
commit 523a4f0d1a
4 changed files with 321 additions and 72 deletions

View File

@ -2,8 +2,9 @@
var keyfetch = module.exports;
var promisify = require("util").promisify;
var requestAsync = promisify(require("@root/request"));
var request = require("@root/request").defaults({
userAgent: "keyfetch/v2.1.0"
});
var Rasha = require("rasha");
var Eckles = require("eckles");
var mincache = 1 * 60 * 60;
@ -11,6 +12,19 @@ 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();
}
return resp;
}
function checkMinDefaultMax(opts, key, n, d, x) {
var i = opts[key];
if (!i && 0 !== i) {
@ -19,7 +33,7 @@ function checkMinDefaultMax(opts, key, n, d, x) {
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);
throw Errors.DEVELOPER_ERROR("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i);
}
}
@ -36,9 +50,10 @@ keyfetch._oidc = async function (iss) {
url: normalizeIss(iss) + "/.well-known/openid-configuration",
json: true
});
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw new Error("Failed to retrieve openid configuration");
throw Errors.OIDC_CONFIG_NOT_FOUND();
}
return oidcConf;
};
@ -47,6 +62,7 @@ keyfetch._wellKnownJwks = async function (iss) {
};
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
@ -104,7 +120,7 @@ function checkId(id) {
})[0];
if (!result) {
throw new Error("No JWK found by kid or thumbprint '" + id + "'");
throw Errors.JWK_NOT_FOUND(id);
}
return result;
};
@ -177,7 +193,7 @@ keyfetch._setCache = function (iss, cacheable) {
function normalizeIss(iss) {
if (!iss) {
throw new Error("'iss' is not defined");
throw Errors.TOKEN_NO_ISSUER();
}
// We definitely don't want false negatives stemming
@ -185,26 +201,26 @@ function normalizeIss(iss) {
// 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."
);
throw Errors.INSECURE_ISSUER(iss);
}
return iss.replace(/\/$/, "");
}
keyfetch.jwt = {};
keyfetch.jwt.decode = function (jwt) {
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;
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.TOKEN_PARSE_ERROR(jwt);
err.details = e.message;
throw err;
}
};
keyfetch.jwt.verify = async function (jwt, opts) {
if (!opts) {
@ -215,6 +231,8 @@ keyfetch.jwt.verify = async function (jwt, opts) {
var exp;
var nbf;
var active;
var now;
var then;
var issuers = opts.issuers || [];
if (opts.iss) {
issuers.push(opts.iss);
@ -223,25 +241,23 @@ keyfetch.jwt.verify = async function (jwt, opts) {
issuers.push(opts.claims.iss);
}
if (!issuers.length) {
throw new 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/"
);
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) {
try {
decoded = keyfetch.jwt.decode(jwt);
} catch (e) {
throw new Error("could not parse jwt: '" + jwt + "'");
}
decoded = keyfetch.jwt.decode(jwt);
} else {
decoded = jwt;
}
exp = decoded.claims.exp;
nbf = decoded.claims.nbf;
if (!issuers.some(isTrustedIssuer(decoded.claims.iss))) {
throw new Error("token was issued by an untrusted issuer: '" + decoded.claims.iss + "'");
if (!decoded.claims.iss || !issuers.some(isTrustedIssuer(decoded.claims.iss))) {
if (!(opts.jwk || opts.jwks)) {
throw Errors.ISSUER_NOT_TRUSTED(decoded.claims.iss || "");
}
}
// Note claims.iss validates more strictly than opts.issuers (requires exact match)
if (
@ -251,20 +267,26 @@ keyfetch.jwt.verify = async function (jwt, opts) {
}
})
) {
throw new Error("token did not match on one or more authorization claims: '" + Object.keys(claims) + "'");
throw Errors.CLAIMS_MISMATCH(Object.keys(claims));
}
active = (opts.exp || 0) + parseInt(exp, 10) - Date.now() / 1000 > 0;
if (!active) {
exp = decoded.claims.exp;
if (exp && false !== opts.exp) {
now = Date.now();
// TODO document that opts.exp can be used as leeway? Or introduce opts.leeway?
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 (exp || false !== opts.exp) {
throw new Error("token's 'exp' has passed or could not parsed: '" + exp + "'");
if (!active) {
throw Errors.TOKEN_EXPIRED(exp);
}
}
nbf = decoded.claims.nbf;
if (nbf) {
active = parseInt(nbf, 10) - Date.now() / 1000 <= 0;
if (!active) {
throw new Error("token's 'nbf' has not been reached or could not parsed: '" + nbf + "'");
throw Errors.TOKEN_INACTIVE(nbf);
}
}
if (opts.jwks || opts.jwk) {
@ -298,7 +320,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
if (true === keyfetch.jws.verify(decoded, hit)) {
return decoded;
}
throw new Error("token signature verification was unsuccessful");
throw Errors.TOKEN_INVALID_SIGNATURE();
}
function verifyAny(hits) {
@ -311,7 +333,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
if (true === keyfetch.jws.verify(decoded, hit)) {
return true;
}
throw new Error("token signature verification was unsuccessful");
throw Errors.TOKEN_INVALID_SIGNATURE();
} else {
if (true === keyfetch.jws.verify(decoded, hit)) {
return true;
@ -321,7 +343,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
) {
return decoded;
}
throw new Error("Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token.");
throw Errors.TOKEN_UNKNOWN_SIGNER();
}
function overrideLookup(jwks) {

176
lib/errors.js Normal file
View File

@ -0,0 +1,176 @@
"use strict";
// Possible User Errors
/**
* @typedef AuthError
* @property {string} message
* @property {number} status
* @property {string} code
* @property {any} [details]
*/
/**
* @param {string} msg
* @param {{
* status: number,
* code: string,
* details?: any,
* }} opts
* @returns {AuthError}
*/
function create(msg, { status = 401, code = "", details }) {
/** @type AuthError */
//@ts-ignore
var err = new Error(msg);
err.message = err.message;
err.status = status;
err.code = code;
if (details) {
err.details = details;
}
err.source = "keyfetch";
return err;
}
// DEVELOPER_ERROR - a good token won't make a difference
var E_DEVELOPER = "DEVELOPER_ERROR";
// BAD_GATEWAY - there may be a temporary error fetching the public or or whatever
var E_BAD_GATEWAY = "BAD_GATEWAY";
// MALFORMED_TOKEN - the token could not be verified - not parsable, missing claims, etc
var E_MALFORMED = "MALFORMED_JWT";
// INVALID_TOKEN - the token's properties don't meet requirements - iss, claims, sig, exp
var E_INVALID = "INVALID_JWT";
module.exports = {
//
// DEVELOPER_ERROR (dev / server)
//
/**
* @param {string} msg
* @returns {AuthError}
*/
DEVELOPER_ERROR: function (msg) {
return create(msg, { status: 500, code: E_DEVELOPER });
},
BAD_GATEWAY: function (/*err*/) {
var msg = "The server encountered a network error or a bad gateway.";
return create(msg, { status: 502, code: E_BAD_GATEWAY });
},
//
// MALFORMED_TOKEN (dev / client)
//
/**
* @param {string} iss
* @returns {AuthError}
*/
INSECURE_ISSUER: function (iss) {
var msg =
"'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing. (iss)";
return create(msg, { status: 400, code: E_MALFORMED });
},
/**
* @param {string} jwt
* @returns {AuthError}
*/
TOKEN_PARSE_ERROR: function (jwt) {
var msg = "could not parse jwt: '" + jwt + "'";
return create(msg, { status: 400, code: E_MALFORMED });
},
/**
* @param {string} iss
* @returns {AuthError}
*/
TOKEN_NO_ISSUER: function (iss) {
var msg = "'iss' is not defined";
return create(msg, { status: 400, code: E_MALFORMED });
},
//
// INVALID_TOKEN (dev / client)
//
/**
* @param {number} exp
* @returns {AuthError}
*/
TOKEN_EXPIRED: function (exp) {
//var msg = "The auth token is expired. (exp='" + exp + "')";
var msg = "token's 'exp' has passed or could not parsed: '" + exp + "'";
return create(msg, { code: E_INVALID });
},
/**
* @param {number} nbf
* @returns {AuthError}
*/
TOKEN_INACTIVE: function (nbf) {
//var msg = "The auth token is not active yet. (nbf='" + nbf + "')";
var msg = "token's 'nbf' has not been reached or could not parsed: '" + nbf + "'";
return create(msg, { code: E_INVALID });
},
/** @returns {AuthError} */
TOKEN_INVALID_SIGNATURE: function () {
//var msg = "The auth token is not properly signed and could not be verified.";
var msg = "token signature verification was unsuccessful";
return create(msg, { code: E_INVALID });
},
/** @returns {AuthError} */
TOKEN_UNKNOWN_SIGNER: function () {
var msg = "Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token.";
return create(msg, { code: E_INVALID });
},
/**
* @param {string} id
* @returns {AuthError}
*/
JWK_NOT_FOUND: function (id) {
var msg = "No JWK found by kid or thumbprint '" + id + "'";
return create(msg, { code: E_INVALID });
},
/** @returns {AuthError} */
OIDC_CONFIG_NOT_FOUND: function () {
//var msg = "Failed to retrieve OpenID configuration for token issuer";
var msg = "Failed to retrieve openid configuration";
return create(msg, { code: E_INVALID });
},
/**
* @param {string} iss
* @returns {AuthError}
*/
ISSUER_NOT_TRUSTED: function (iss) {
var msg = "token was issued by an untrusted issuer: '" + iss + "'";
return create(msg, { code: E_INVALID });
},
/**
* @param {Array<string>} claimNames
* @returns {AuthError}
*/
CLAIMS_MISMATCH: function (claimNames) {
var msg = "token did not match on one or more authorization claims: '" + claimNames + "'";
return create(msg, { code: E_INVALID });
}
};
// for README
if (require.main === module) {
console.info("| Name | Status | Message (truncated) |");
console.info("| ---- | ------ | ------------------- |");
Object.keys(module.exports).forEach(function (k) {
//@ts-ignore
var E = module.exports[k];
var e = E();
var code = e.code;
var msg = e.message;
if ("E_" + k !== e.code) {
code = k;
msg = e.details || msg;
}
console.info(`| ${code} | ${e.status} | ${msg.slice(0, 45)}... |`);
});
}

107
package-lock.json generated
View File

@ -1,33 +1,82 @@
{
"name": "keyfetch",
"version": "2.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@root/request": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.7.0.tgz",
"integrity": "sha512-lre7XVeEwszgyrayWWb/kRn5fuJfa+n0Nh+rflM9E+EpC28yIYA+FPm/OL1uhzp3TxhQM0HFN4FE2RDIPGlnmg=="
"name": "keyfetch",
"version": "2.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "keyfetch",
"version": "2.0.0",
"license": "MPL-2.0",
"dependencies": {
"@root/request": "^1.8.0",
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},
"devDependencies": {
"keypairs": "^1.2.14"
}
},
"node_modules/@root/request": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.8.0.tgz",
"integrity": "sha512-HufCvoTwqR30OyKSjwg28W5QCUpypSJZpOYcJbC9PME5kI6cOYsccYs/6bXfsuEoarz8+YwBDrsuM1UdBMxMLw=="
},
"node_modules/eckles": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz",
"integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA==",
"bin": {
"eckles": "bin/eckles.js"
}
},
"node_modules/keypairs": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.14.tgz",
"integrity": "sha512-ZoZfZMygyB0QcjSlz7Rh6wT2CJasYEHBPETtmHZEfxuJd7bnsOG5AdtPZqHZBT+hoHvuWCp/4y8VmvTvH0Y9uA==",
"dev": true,
"dependencies": {
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},
"bin": {
"keypairs-install": "bin/keypairs.js"
}
},
"node_modules/rasha": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz",
"integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q==",
"bin": {
"rasha": "bin/rasha.js"
}
}
},
"eckles": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz",
"integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA=="
},
"keypairs": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.14.tgz",
"integrity": "sha512-ZoZfZMygyB0QcjSlz7Rh6wT2CJasYEHBPETtmHZEfxuJd7bnsOG5AdtPZqHZBT+hoHvuWCp/4y8VmvTvH0Y9uA==",
"dev": true,
"requires": {
"eckles": "^1.4.1",
"rasha": "^1.2.4"
}
},
"rasha": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz",
"integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q=="
"dependencies": {
"@root/request": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.8.0.tgz",
"integrity": "sha512-HufCvoTwqR30OyKSjwg28W5QCUpypSJZpOYcJbC9PME5kI6cOYsccYs/6bXfsuEoarz8+YwBDrsuM1UdBMxMLw=="
},
"eckles": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz",
"integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA=="
},
"keypairs": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.14.tgz",
"integrity": "sha512-ZoZfZMygyB0QcjSlz7Rh6wT2CJasYEHBPETtmHZEfxuJd7bnsOG5AdtPZqHZBT+hoHvuWCp/4y8VmvTvH0Y9uA==",
"dev": true,
"requires": {
"eckles": "^1.4.1",
"rasha": "^1.2.4"
}
},
"rasha": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz",
"integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q=="
}
}
}
}

View File

@ -4,9 +4,11 @@
"description": "Lightweight support for fetching JWKs.",
"homepage": "https://git.rootprojects.org/root/keyfetch.js",
"main": "keyfetch.js",
"files": [],
"files": [
"lib"
],
"dependencies": {
"@root/request": "^1.7.0",
"@root/request": "^1.8.0",
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},