From 523a4f0d1a70b6c56ac248044be1553cf2a4a022 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 20 Oct 2021 18:12:06 -0600 Subject: [PATCH] feature: add distinct error codes --- keyfetch.js | 104 ++++++++++++++++----------- lib/errors.js | 176 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 107 ++++++++++++++++++++-------- package.json | 6 +- 4 files changed, 321 insertions(+), 72 deletions(-) create mode 100644 lib/errors.js diff --git a/keyfetch.js b/keyfetch.js index a13d8c7..2999fed 100644 --- a/keyfetch.js +++ b/keyfetch.js @@ -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) { diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..971a1fb --- /dev/null +++ b/lib/errors.js @@ -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} 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)}... |`); + }); +} diff --git a/package-lock.json b/package-lock.json index 5cf16dd..b47241a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" + } } - } } diff --git a/package.json b/package.json index caf535a..5e533c6 100644 --- a/package.json +++ b/package.json @@ -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" },