feature: add additional (standardized) error messages

This commit is contained in:
AJ ONeal 2021-10-21 13:21:41 -06:00
parent 523a4f0d1a
commit a3539b0941
4 changed files with 234 additions and 91 deletions

11
CHANGELOG.md Normal file
View File

@ -0,0 +1,11 @@
# v3.0.0
**Breaking Change**: Standardize error `message`s (now they're more client-friendly).
# v2.1.0
Feature: Add `code`, `status`, and `details` to errors.
# v2.0.0
**Breaking Change**: require `issuers` array (rather than `["*"]` by default).

View File

@ -11,7 +11,7 @@ Works great for
- [x] `jsonwebtoken` (Auth0) - [x] `jsonwebtoken` (Auth0)
- [x] OIDC (OpenID Connect) - [x] OIDC (OpenID Connect)
- [x] .well-known/jwks.json (Auth0) - [x] .well-known/jwks.json (Auth0, Okta)
- [x] Other JWKs URLs - [x] Other JWKs URLs
Crypto Support Crypto Support
@ -19,8 +19,19 @@ Crypto Support
- [x] JWT verification - [x] JWT verification
- [x] RSA (all variants) - [x] RSA (all variants)
- [x] EC / ECDSA (NIST variants P-256, P-384) - [x] EC / ECDSA (NIST variants P-256, P-384)
- [x] Sane error codes
- [ ] esoteric variants (excluded to keep the code featherweight and secure) - [ ] esoteric variants (excluded to keep the code featherweight and secure)
# Table of Contents
- Install
- Usage
- API
- Auth0 / Okta
- OIDC
- Errors
- Change Log
# Install # Install
```bash ```bash
@ -248,3 +259,49 @@ keyfetch.init({
There is no background task to cleanup expired keys as of yet. There is no background task to cleanup expired keys as of yet.
For now you can limit the number of keys fetched by having a simple whitelist. For now you can limit the number of keys fetched by having a simple whitelist.
# Errors
`JSON.stringify()`d errors look like this:
```js
{
code: "INVALID_JWT",
status: 401,
details: [ "jwt.claims.exp = 1634804500", "DEBUG: helpful message" ]
message: "token's 'exp' has passed or could not parsed: 1634804500"
}
```
SemVer Compatibility:
- `code` & `status` will remain the same.
- The `message` property of an error is **NOT** included in the semver compatibility guarantee (we intend to make them more client-friendly), neither is `detail` at this time (but it will be once we decide on what it should be).
For backwards compatibility with v1, the non-stringified `message` is the same as what it was in v1 (and the v2 message is `client_message`, which replaces `message` in v3). Don't rely on it. Rely on `code`.
| Hint | Code | Status | Message (truncated) |
| ------------------- | --------------- | ------ | ------------------------------------------------ |
| (developer error) | DEVELOPER_ERROR | 500 | test... |
| (bad gateway) | BAD_GATEWAY | 502 | The token could not be verified because our s... |
| (insecure issuer) | MALFORMED_JWT | 400 | 'test' is NOT secure. Set env 'KEYFETCH_ALLOW... |
| (parse error) | MALFORMED_JWT | 400 | could not parse jwt: 'test'... |
| (no issuer) | MALFORMED_JWT | 400 | 'iss' is not defined... |
| (malformed exp) | MALFORMED_JWT | 400 | token's 'exp' has passed or could not parsed:... |
| (expired) | INVALID_JWT | 401 | token's 'exp' has passed or could not parsed:... |
| (inactive) | INVALID_JWT | 401 | token's 'nbf' has not been reached or could n... |
| (bad signature) | INVALID_JWT | 401 | token signature verification was unsuccessful... |
| (jwk not found old) | INVALID_JWT | 401 | Retrieved a list of keys, but none of them ma... |
| (jwk not found) | INVALID_JWT | 401 | No JWK found by kid or thumbprint 'test'... |
| (no jwkws uri) | INVALID_JWT | 401 | Failed to retrieve openid configuration... |
| (unknown issuer) | INVALID_JWT | 401 | token was issued by an untrusted issuer: 'tes... |
| (failed claims) | INVALID_JWT | 401 | token did not match on one or more authorizat... |
# Change Log
Minor Breaking changes (with a major version bump):
- v3.0.0 - reworked error messages (also available in v2.1.0 as `client_message`)
- v2.0.0 - changes from the default `issuers = ["*"]` to requiring that an issuer (or public jwk for verification) is specified
See other changes in [CHANGELOG.md](./CHANGELOG.md).

View File

@ -19,7 +19,7 @@ async function requestAsync(req) {
// differentiate potentially temporary server errors from 404 // differentiate potentially temporary server errors from 404
if (!resp.ok && (resp.statusCode >= 500 || resp.statusCode < 200)) { if (!resp.ok && (resp.statusCode >= 500 || resp.statusCode < 200)) {
throw Errors.BAD_GATEWAY(); throw Errors.BAD_GATEWAY({ response: resp });
} }
return resp; return resp;
@ -37,6 +37,8 @@ function checkMinDefaultMax(opts, key, n, d, x) {
} }
} }
keyfetch._errors = Errors;
keyfetch._clear = function () { keyfetch._clear = function () {
keyCache = {}; keyCache = {};
}; };
@ -46,14 +48,15 @@ keyfetch.init = function (opts) {
staletime = checkMinDefaultMax(opts, "staletime", 1 * 60, staletime, 31 * 24 * 60 * 60); staletime = checkMinDefaultMax(opts, "staletime", 1 * 60, staletime, 31 * 24 * 60 * 60);
}; };
keyfetch._oidc = async function (iss) { keyfetch._oidc = async function (iss) {
var url = normalizeIss(iss) + "/.well-known/openid-configuration";
var resp = await requestAsync({ var resp = await requestAsync({
url: normalizeIss(iss) + "/.well-known/openid-configuration", url: url,
json: true json: true
}); });
var oidcConf = resp.body; var oidcConf = resp.body;
if (!oidcConf.jwks_uri) { if (!oidcConf.jwks_uri) {
throw Errors.OIDC_CONFIG_NOT_FOUND(); throw Errors.NO_JWKS_URI(url);
} }
return oidcConf; return oidcConf;
}; };
@ -193,7 +196,7 @@ keyfetch._setCache = function (iss, cacheable) {
function normalizeIss(iss) { function normalizeIss(iss) {
if (!iss) { if (!iss) {
throw Errors.TOKEN_NO_ISSUER(); throw Errors.NO_ISSUER();
} }
// We definitely don't want false negatives stemming // We definitely don't want false negatives stemming
@ -217,7 +220,7 @@ keyfetch.jwt.decode = function (jwt) {
obj.claims = JSON.parse(Buffer.from(obj.payload, "base64")); obj.claims = JSON.parse(Buffer.from(obj.payload, "base64"));
return obj; return obj;
} catch (e) { } catch (e) {
var err = Errors.TOKEN_PARSE_ERROR(jwt); var err = Errors.PARSE_ERROR(jwt);
err.details = e.message; err.details = e.message;
throw err; throw err;
} }
@ -227,7 +230,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
opts = {}; opts = {};
} }
var decoded; var jws;
var exp; var exp;
var nbf; var nbf;
var active; var active;
@ -249,60 +252,68 @@ keyfetch.jwt.verify = async function (jwt, opts) {
} }
var claims = opts.claims || {}; var claims = opts.claims || {};
if (!jwt || "string" === typeof jwt) { if (!jwt || "string" === typeof jwt) {
decoded = keyfetch.jwt.decode(jwt); jws = keyfetch.jwt.decode(jwt);
} else { } else {
decoded = jwt; jws = jwt;
} }
if (!decoded.claims.iss || !issuers.some(isTrustedIssuer(decoded.claims.iss))) { if (!jws.claims.iss || !issuers.some(isTrustedIssuer(jws.claims.iss))) {
if (!(opts.jwk || opts.jwks)) { if (!(opts.jwk || opts.jwks)) {
throw Errors.ISSUER_NOT_TRUSTED(decoded.claims.iss || ""); throw Errors.UNKNOWN_ISSUER(jws.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 ( var failedClaims = Object.keys(claims)
!Object.keys(claims).every(function (key) { .filter(function (key) {
if (claims[key] === decoded.claims[key]) { if (claims[key] !== jws.claims[key]) {
return true; return true;
} }
}) })
) { .map(function (key) {
throw Errors.CLAIMS_MISMATCH(Object.keys(claims)); return "jwt.claims." + key + " = " + JSON.stringify(jws.claims[key]);
});
if (failedClaims.length) {
throw Errors.FAILED_CLAIMS(failedClaims, Object.keys(claims));
} }
exp = decoded.claims.exp; exp = jws.claims.exp;
if (exp && false !== opts.exp) { if (exp && false !== opts.exp) {
now = Date.now(); now = Date.now();
// TODO document that opts.exp can be used as leeway? Or introduce opts.leeway? // 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); then = (opts.exp || 0) + parseInt(exp, 10);
active = then - now / 1000 > 0; 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 (!active) { if (!active) {
throw Errors.TOKEN_EXPIRED(exp); throw Errors.EXPIRED(exp);
} }
} }
nbf = decoded.claims.nbf; nbf = jws.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 Errors.TOKEN_INACTIVE(nbf); throw Errors.INACTIVE(nbf);
} }
} }
if (opts.jwks || opts.jwk) { if (opts.jwks || opts.jwk) {
return overrideLookup(opts.jwks || [opts.jwk]); return overrideLookup(opts.jwks || [opts.jwk]);
} }
var kid = decoded.header.kid; var kid = jws.header.kid;
var iss; var iss;
var fetcher; var fetcher;
var fetchOne; var fetchOne;
if (!opts.strategy || "oidc" === opts.strategy) { if (!opts.strategy || "oidc" === opts.strategy) {
iss = decoded.claims.iss; iss = jws.claims.iss;
fetcher = keyfetch.oidcJwks; fetcher = keyfetch.oidcJwks;
fetchOne = keyfetch.oidcJwk; fetchOne = keyfetch.oidcJwk;
} else if ("auth0" === opts.strategy || "well-known" === opts.strategy) { } else if ("auth0" === opts.strategy || "well-known" === opts.strategy) {
iss = decoded.claims.iss; iss = jws.claims.iss;
fetcher = keyfetch.wellKnownJwks; fetcher = keyfetch.wellKnownJwks;
fetchOne = keyfetch.wellKnownJwk; fetchOne = keyfetch.wellKnownJwk;
} else { } else {
@ -317,10 +328,10 @@ keyfetch.jwt.verify = async function (jwt, opts) {
return fetcher(iss).then(verifyAny); return fetcher(iss).then(verifyAny);
function verifyOne(hit) { function verifyOne(hit) {
if (true === keyfetch.jws.verify(decoded, hit)) { if (true === keyfetch.jws.verify(jws, hit)) {
return decoded; return jws;
} }
throw Errors.TOKEN_INVALID_SIGNATURE(); throw Errors.BAD_SIGNATURE(jws.protected + "." + jws.payload + "." + jws.signature);
} }
function verifyAny(hits) { function verifyAny(hits) {
@ -330,20 +341,19 @@ keyfetch.jwt.verify = async function (jwt, opts) {
if (kid !== hit.jwk.kid && kid !== hit.thumbprint) { if (kid !== hit.jwk.kid && kid !== hit.thumbprint) {
return; return;
} }
if (true === keyfetch.jws.verify(decoded, hit)) { if (true === keyfetch.jws.verify(jws, hit)) {
return true;
}
throw Errors.TOKEN_INVALID_SIGNATURE();
} else {
if (true === keyfetch.jws.verify(decoded, hit)) {
return true; return true;
} }
throw Errors.BAD_SIGNATURE();
}
if (true === keyfetch.jws.verify(jws, hit)) {
return true;
} }
}) })
) { ) {
return decoded; return jws;
} }
throw Errors.TOKEN_UNKNOWN_SIGNER(); throw Errors.JWK_NOT_FOUND_OLD(kid);
} }
function overrideLookup(jwks) { function overrideLookup(jwks) {

View File

@ -19,30 +19,46 @@
* }} opts * }} opts
* @returns {AuthError} * @returns {AuthError}
*/ */
function create(msg, { status = 401, code = "", details }) { function create(old, msg, code, status, details) {
/** @type AuthError */ /** @type AuthError */
//@ts-ignore //@ts-ignore
var err = new Error(msg); var err = new Error(old);
err.message = err.message; err.client_message = msg;
err.status = status;
err.code = code; err.code = code;
err.status = status;
if (details) { if (details) {
err.details = details; err.details = details;
} }
err.source = "keyfetch"; err.source = "keyfetch";
err.toJSON = toJSON;
err.toString = toString;
return err; return err;
} }
function toJSON() {
/*jshint validthis:true*/
return {
message: this.client_message,
status: this.status,
code: this.code,
details: this.details
};
}
function toString() {
/*jshint validthis:true*/
return this.stack + "\n" + JSON.stringify(this);
}
// DEVELOPER_ERROR - a good token won't make a difference // DEVELOPER_ERROR - a good token won't make a difference
var E_DEVELOPER = "DEVELOPER_ERROR"; var E_DEVELOPER = "DEVELOPER_ERROR";
// BAD_GATEWAY - there may be a temporary error fetching the public or or whatever // BAD_GATEWAY - there may be a temporary error fetching the public or or whatever
var E_BAD_GATEWAY = "BAD_GATEWAY"; var E_BAD_GATEWAY = "BAD_GATEWAY";
// MALFORMED_TOKEN - the token could not be verified - not parsable, missing claims, etc // MALFORMED_JWT - the token could not be verified - not parsable, missing claims, etc
var E_MALFORMED = "MALFORMED_JWT"; var E_MALFORMED = "MALFORMED_JWT";
// INVALID_TOKEN - the token's properties don't meet requirements - iss, claims, sig, exp // INVALID_JWT - the token's properties don't meet requirements - iss, claims, sig, exp
var E_INVALID = "INVALID_JWT"; var E_INVALID = "INVALID_JWT";
module.exports = { module.exports = {
@ -54,12 +70,20 @@ module.exports = {
* @param {string} msg * @param {string} msg
* @returns {AuthError} * @returns {AuthError}
*/ */
DEVELOPER_ERROR: function (msg) { DEVELOPER_ERROR: function (old, msg, details) {
return create(msg, { status: 500, code: E_DEVELOPER }); return create(old, msg, E_DEVELOPER, 500, details);
}, },
BAD_GATEWAY: function (/*err*/) { BAD_GATEWAY: function (err) {
var msg = "The server encountered a network error or a bad gateway."; var msg =
return create(msg, { status: 502, code: E_BAD_GATEWAY }); "The token could not be verified because our server encountered a network error (or a bad gateway) when connecting to its issuing server.";
var details = [];
if (err.message) {
details.push("error.message = " + err.message);
}
if (err.response && err.response.statusCode) {
details.push("response.statusCode = " + err.response.statusCode);
}
return create(msg, msg, E_BAD_GATEWAY, 502);
}, },
// //
@ -71,25 +95,46 @@ module.exports = {
* @returns {AuthError} * @returns {AuthError}
*/ */
INSECURE_ISSUER: function (iss) { INSECURE_ISSUER: function (iss) {
var msg = var old =
"'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing. (iss)"; "'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing. (iss)";
return create(msg, { status: 400, code: E_MALFORMED }); var details = [
"jwt.claims.iss = " + JSON.stringify(iss),
"DEBUG: Set ENV 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow insecure issuers (for testing)."
];
var msg =
'The token could not be verified because our server could connect to its issuing server ("iss") securely.';
return create(old, msg, E_MALFORMED, 400, details);
}, },
/** /**
* @param {string} jwt * @param {string} jwt
* @returns {AuthError} * @returns {AuthError}
*/ */
TOKEN_PARSE_ERROR: function (jwt) { PARSE_ERROR: function (jwt) {
var msg = "could not parse jwt: '" + jwt + "'"; var old = "could not parse jwt: '" + jwt + "'";
return create(msg, { status: 400, code: E_MALFORMED }); var msg = "The auth token is malformed.";
var details = ["jwt = " + JSON.stringify(jwt)];
return create(old, msg, E_MALFORMED, 400, details);
}, },
/** /**
* @param {string} iss * @param {string} iss
* @returns {AuthError} * @returns {AuthError}
*/ */
TOKEN_NO_ISSUER: function (iss) { NO_ISSUER: function (iss) {
var msg = "'iss' is not defined"; var old = "'iss' is not defined";
return create(msg, { status: 400, code: E_MALFORMED }); var msg = 'The token could not be verified because it doesn\'t specify an issuer ("iss").';
var details = ["jwt.claims.iss = " + JSON.stringify(iss)];
return create(old, msg, E_MALFORMED, 400, details);
},
/**
* @param {string} iss
* @returns {AuthError}
*/
MALFORMED_EXP: function (exp) {
var old = "token's 'exp' has passed or could not parsed: '" + exp + "'";
var msg = 'The auth token could not be verified because it\'s expiration date ("exp") could not be read';
var details = ["jwt.claims.exp = " + JSON.stringify(exp)];
return create(old, msg, E_MALFORMED, 400, details);
}, },
// //
@ -100,77 +145,97 @@ module.exports = {
* @param {number} exp * @param {number} exp
* @returns {AuthError} * @returns {AuthError}
*/ */
TOKEN_EXPIRED: function (exp) { EXPIRED: function (exp) {
//var msg = "The auth token is expired. (exp='" + exp + "')"; var old = "token's 'exp' has passed or could not parsed: '" + exp + "'";
var msg = "token's 'exp' has passed or could not parsed: '" + exp + "'"; // var msg = "The auth token did not pass verification because it is expired.not properly signed.";
return create(msg, { code: E_INVALID }); var msg = "The auth token is expired. To try again, go to the main page and sign in.";
var details = ["jwt.claims.exp = " + JSON.stringify(exp)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** /**
* @param {number} nbf * @param {number} nbf
* @returns {AuthError} * @returns {AuthError}
*/ */
TOKEN_INACTIVE: function (nbf) { INACTIVE: function (nbf) {
//var msg = "The auth token is not active yet. (nbf='" + nbf + "')"; var old = "token's 'nbf' has not been reached or could not parsed: '" + nbf + "'";
var msg = "token's 'nbf' has not been reached or could not parsed: '" + nbf + "'"; var msg = "The auth token isn't valid yet. It's activation date (\"nbf\") is in the future.";
return create(msg, { code: E_INVALID }); var details = ["jwt.claims.nbf = " + JSON.stringify(nbf)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** @returns {AuthError} */ /** @returns {AuthError} */
TOKEN_INVALID_SIGNATURE: function () { BAD_SIGNATURE: function (jwt) {
//var msg = "The auth token is not properly signed and could not be verified."; var old = "token signature verification was unsuccessful";
var msg = "token signature verification was unsuccessful"; var msg = "The auth token did not pass verification because it is not properly signed.";
return create(msg, { code: E_INVALID }); var details = ["jwt = " + JSON.stringify(jwt)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** @returns {AuthError} */ /**
TOKEN_UNKNOWN_SIGNER: function () { * @param {string} kid
var msg = "Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token."; * @returns {AuthError}
return create(msg, { code: E_INVALID }); */
JWK_NOT_FOUND_OLD: function (kid) {
var old = "Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token.";
var msg =
'The auth token did not pass verification because our server couldn\'t find a mutually trusted verification key ("jwk").';
var details = ["jws.header.kid = " + JSON.stringify(kid)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** /**
* @param {string} id * @param {string} id
* @returns {AuthError} * @returns {AuthError}
*/ */
JWK_NOT_FOUND: function (id) { JWK_NOT_FOUND: function (id) {
var msg = "No JWK found by kid or thumbprint '" + id + "'"; // TODO Distinguish between when it's a kid vs thumbprint.
return create(msg, { code: E_INVALID }); var old = "No JWK found by kid or thumbprint '" + id + "'";
var msg =
'The auth token did not pass verification because our server couldn\'t find a mutually trusted verification key ("jwk").';
var details = ["jws.header.kid = " + JSON.stringify(id)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** @returns {AuthError} */ /** @returns {AuthError} */
OIDC_CONFIG_NOT_FOUND: function () { NO_JWKWS_URI: function (url) {
//var msg = "Failed to retrieve OpenID configuration for token issuer"; var old = "Failed to retrieve openid configuration";
var msg = "Failed to retrieve openid configuration"; var msg =
return create(msg, { code: E_INVALID }); 'The auth token did not pass verification because its issuing server did not list any verification keys ("jwks").';
var details = ["OpenID Provider Configuration: " + JSON.stringify(url)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** /**
* @param {string} iss * @param {string} iss
* @returns {AuthError} * @returns {AuthError}
*/ */
ISSUER_NOT_TRUSTED: function (iss) { UNKNOWN_ISSUER: function (iss) {
var msg = "token was issued by an untrusted issuer: '" + iss + "'"; var old = "token was issued by an untrusted issuer: '" + iss + "'";
return create(msg, { code: E_INVALID }); var msg = "The auth token did not pass verification because it wasn't issued by a server that we trust.";
var details = ["jwt.claims.iss = " + JSON.stringify(iss)];
return create(old, msg, E_INVALID, 401, details);
}, },
/** /**
* @param {Array<string>} claimNames * @param {Array<string>} details
* @returns {AuthError} * @returns {AuthError}
*/ */
CLAIMS_MISMATCH: function (claimNames) { FAILED_CLAIMS: function (details, claimNames) {
var msg = "token did not match on one or more authorization claims: '" + claimNames + "'"; var old = "token did not match on one or more authorization claims: '" + claimNames + "'";
return create(msg, { code: E_INVALID }); var msg =
'The auth token did not pass verification because it failed some of the verification criteria ("claims").';
return create(old, msg, E_INVALID, 401, details);
} }
}; };
var Errors = module.exports;
// for README // for README
if (require.main === module) { if (require.main === module) {
console.info("| Name | Status | Message (truncated) |"); console.info("| Hint | Code | Status | Message (truncated) |");
console.info("| ---- | ------ | ------------------- |"); console.info("| ---- | ---- | ------ | ------------------- |");
Object.keys(module.exports).forEach(function (k) { Object.keys(module.exports).forEach(function (k) {
//@ts-ignore //@ts-ignore
var E = module.exports[k]; var E = module.exports[k];
var e = E(); var e = E("test");
var code = e.code; var code = e.code;
var msg = e.message; var msg = e.message.slice(0, 45);
if ("E_" + k !== e.code) { var hint = k.toLowerCase().replace(/_/g, " ");
code = k; console.info(`| (${hint}) | ${code} | ${e.status} | ${msg}... |`);
msg = e.details || msg;
}
console.info(`| ${code} | ${e.status} | ${msg.slice(0, 45)}... |`);
}); });
console.log(Errors.MALFORMED_EXP());
console.log(JSON.stringify(Errors.MALFORMED_EXP(), null, 2));
} }