Compare commits

...

2 Commits

Author SHA1 Message Date
AJ ONeal 523a4f0d1a feature: add distinct error codes 2021-10-20 18:12:06 -06:00
AJ ONeal 7d5889b4de chore: add linter config 2021-10-20 18:08:27 -06:00
5 changed files with 343 additions and 72 deletions

22
.jshintrc Normal file
View File

@ -0,0 +1,22 @@
{
"browser": true,
"node": true,
"esversion": 11,
"curly": true,
"sub": true,
"bitwise": true,
"eqeqeq": true,
"forin": true,
"freeze": true,
"immed": true,
"latedef": "nofunc",
"nonbsp": true,
"nonew": true,
"plusplus": true,
"undef": true,
"unused": "vars",
"strict": true,
"maxdepth": 3,
"maxstatements": 100,
"maxcomplexity": 10
}

View File

@ -2,8 +2,9 @@
var keyfetch = module.exports; var keyfetch = module.exports;
var promisify = require("util").promisify; var request = require("@root/request").defaults({
var requestAsync = promisify(require("@root/request")); userAgent: "keyfetch/v2.1.0"
});
var Rasha = require("rasha"); var Rasha = require("rasha");
var Eckles = require("eckles"); var Eckles = require("eckles");
var mincache = 1 * 60 * 60; var mincache = 1 * 60 * 60;
@ -11,6 +12,19 @@ var maxcache = 3 * 24 * 60 * 60;
var staletime = 15 * 60; var staletime = 15 * 60;
var keyCache = {}; 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) { function checkMinDefaultMax(opts, key, n, d, x) {
var i = opts[key]; var i = opts[key];
if (!i && 0 !== i) { if (!i && 0 !== i) {
@ -19,7 +33,7 @@ function checkMinDefaultMax(opts, key, n, d, x) {
if (i >= n && i >= x) { if (i >= n && i >= x) {
return parseInt(i, 10); return parseInt(i, 10);
} else { } 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", url: normalizeIss(iss) + "/.well-known/openid-configuration",
json: true json: true
}); });
var oidcConf = resp.body; var oidcConf = resp.body;
if (!oidcConf.jwks_uri) { if (!oidcConf.jwks_uri) {
throw new Error("Failed to retrieve openid configuration"); throw Errors.OIDC_CONFIG_NOT_FOUND();
} }
return oidcConf; return oidcConf;
}; };
@ -47,6 +62,7 @@ keyfetch._wellKnownJwks = async function (iss) {
}; };
keyfetch._jwks = async function (iss) { keyfetch._jwks = async function (iss) {
var resp = await requestAsync({ url: iss, json: true }); var resp = await requestAsync({ url: iss, json: true });
return Promise.all( return Promise.all(
resp.body.keys.map(async function (jwk) { resp.body.keys.map(async function (jwk) {
// EC keys have an x values, whereas RSA keys do not // EC keys have an x values, whereas RSA keys do not
@ -104,7 +120,7 @@ function checkId(id) {
})[0]; })[0];
if (!result) { if (!result) {
throw new Error("No JWK found by kid or thumbprint '" + id + "'"); throw Errors.JWK_NOT_FOUND(id);
} }
return result; return result;
}; };
@ -177,7 +193,7 @@ keyfetch._setCache = function (iss, cacheable) {
function normalizeIss(iss) { function normalizeIss(iss) {
if (!iss) { if (!iss) {
throw new Error("'iss' is not defined"); throw Errors.TOKEN_NO_ISSUER();
} }
// We definitely don't want false negatives stemming // We definitely don't want false negatives stemming
@ -185,26 +201,26 @@ function normalizeIss(iss) {
// We also don't want to allow insecure issuers // We also don't want to allow insecure issuers
if (/^http:/.test(iss) && !process.env.KEYFETCH_ALLOW_INSECURE_HTTP) { if (/^http:/.test(iss) && !process.env.KEYFETCH_ALLOW_INSECURE_HTTP) {
// note, we wrap some things in promises just so we can throw here // note, we wrap some things in promises just so we can throw here
throw new Error( throw Errors.INSECURE_ISSUER(iss);
"'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing."
);
} }
return iss.replace(/\/$/, ""); return iss.replace(/\/$/, "");
} }
keyfetch.jwt = {}; keyfetch.jwt = {};
keyfetch.jwt.decode = function (jwt) { keyfetch.jwt.decode = function (jwt) {
try {
var parts = jwt.split("."); var parts = jwt.split(".");
// JWS // JWS
var obj = { var obj = { protected: parts[0], payload: parts[1], signature: parts[2] };
protected: parts[0],
payload: parts[1],
signature: parts[2]
};
// JWT // JWT
obj.header = JSON.parse(Buffer.from(obj.protected, "base64")); obj.header = JSON.parse(Buffer.from(obj.protected, "base64"));
obj.claims = JSON.parse(Buffer.from(obj.payload, "base64")); obj.claims = JSON.parse(Buffer.from(obj.payload, "base64"));
return obj; return obj;
} catch (e) {
var err = Errors.TOKEN_PARSE_ERROR(jwt);
err.details = e.message;
throw err;
}
}; };
keyfetch.jwt.verify = async function (jwt, opts) { keyfetch.jwt.verify = async function (jwt, opts) {
if (!opts) { if (!opts) {
@ -215,6 +231,8 @@ keyfetch.jwt.verify = async function (jwt, opts) {
var exp; var exp;
var nbf; var nbf;
var active; var active;
var now;
var then;
var issuers = opts.issuers || []; var issuers = opts.issuers || [];
if (opts.iss) { if (opts.iss) {
issuers.push(opts.iss); issuers.push(opts.iss);
@ -223,25 +241,23 @@ keyfetch.jwt.verify = async function (jwt, opts) {
issuers.push(opts.claims.iss); issuers.push(opts.claims.iss);
} }
if (!issuers.length) { if (!issuers.length) {
throw new Error( 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/" "[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 || {}; var claims = opts.claims || {};
if (!jwt || "string" === typeof jwt) { if (!jwt || "string" === typeof jwt) {
try {
decoded = keyfetch.jwt.decode(jwt); decoded = keyfetch.jwt.decode(jwt);
} catch (e) {
throw new Error("could not parse jwt: '" + jwt + "'");
}
} else { } else {
decoded = jwt; decoded = jwt;
} }
exp = decoded.claims.exp;
nbf = decoded.claims.nbf;
if (!issuers.some(isTrustedIssuer(decoded.claims.iss))) { if (!decoded.claims.iss || !issuers.some(isTrustedIssuer(decoded.claims.iss))) {
throw new Error("token was issued by an untrusted issuer: '" + 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) // Note claims.iss validates more strictly than opts.issuers (requires exact match)
if ( 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; exp = decoded.claims.exp;
if (!active) { 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 // expiration was on the token or, if not, such a token is not allowed
if (exp || false !== opts.exp) { if (!active) {
throw new Error("token's 'exp' has passed or could not parsed: '" + exp + "'"); throw Errors.TOKEN_EXPIRED(exp);
} }
} }
nbf = decoded.claims.nbf;
if (nbf) { if (nbf) {
active = parseInt(nbf, 10) - Date.now() / 1000 <= 0; active = parseInt(nbf, 10) - Date.now() / 1000 <= 0;
if (!active) { 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) { if (opts.jwks || opts.jwk) {
@ -298,7 +320,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
if (true === keyfetch.jws.verify(decoded, hit)) { if (true === keyfetch.jws.verify(decoded, hit)) {
return decoded; return decoded;
} }
throw new Error("token signature verification was unsuccessful"); throw Errors.TOKEN_INVALID_SIGNATURE();
} }
function verifyAny(hits) { function verifyAny(hits) {
@ -311,7 +333,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
if (true === keyfetch.jws.verify(decoded, hit)) { if (true === keyfetch.jws.verify(decoded, hit)) {
return true; return true;
} }
throw new Error("token signature verification was unsuccessful"); throw Errors.TOKEN_INVALID_SIGNATURE();
} else { } else {
if (true === keyfetch.jws.verify(decoded, hit)) { if (true === keyfetch.jws.verify(decoded, hit)) {
return true; return true;
@ -321,7 +343,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
) { ) {
return decoded; 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) { 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)}... |`);
});
}

57
package-lock.json generated
View File

@ -1,13 +1,62 @@
{ {
"name": "keyfetch", "name": "keyfetch",
"version": "2.0.0", "version": "2.0.0",
"lockfileVersion": 1, "lockfileVersion": 2,
"requires": true, "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"
}
}
},
"dependencies": { "dependencies": {
"@root/request": { "@root/request": {
"version": "1.7.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@root/request/-/request-1.8.0.tgz",
"integrity": "sha512-lre7XVeEwszgyrayWWb/kRn5fuJfa+n0Nh+rflM9E+EpC28yIYA+FPm/OL1uhzp3TxhQM0HFN4FE2RDIPGlnmg==" "integrity": "sha512-HufCvoTwqR30OyKSjwg28W5QCUpypSJZpOYcJbC9PME5kI6cOYsccYs/6bXfsuEoarz8+YwBDrsuM1UdBMxMLw=="
}, },
"eckles": { "eckles": {
"version": "1.4.1", "version": "1.4.1",

View File

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