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] OIDC (OpenID Connect)
- [x] .well-known/jwks.json (Auth0)
- [x] .well-known/jwks.json (Auth0, Okta)
- [x] Other JWKs URLs
Crypto Support
@ -19,8 +19,19 @@ Crypto Support
- [x] JWT verification
- [x] RSA (all variants)
- [x] EC / ECDSA (NIST variants P-256, P-384)
- [x] Sane error codes
- [ ] esoteric variants (excluded to keep the code featherweight and secure)
# Table of Contents
- Install
- Usage
- API
- Auth0 / Okta
- OIDC
- Errors
- Change Log
# Install
```bash
@ -248,3 +259,49 @@ keyfetch.init({
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.
# 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
if (!resp.ok && (resp.statusCode >= 500 || resp.statusCode < 200)) {
throw Errors.BAD_GATEWAY();
throw Errors.BAD_GATEWAY({ response: resp });
}
return resp;
@ -37,6 +37,8 @@ function checkMinDefaultMax(opts, key, n, d, x) {
}
}
keyfetch._errors = Errors;
keyfetch._clear = function () {
keyCache = {};
};
@ -46,14 +48,15 @@ keyfetch.init = function (opts) {
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: normalizeIss(iss) + "/.well-known/openid-configuration",
url: url,
json: true
});
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw Errors.OIDC_CONFIG_NOT_FOUND();
throw Errors.NO_JWKS_URI(url);
}
return oidcConf;
};
@ -193,7 +196,7 @@ keyfetch._setCache = function (iss, cacheable) {
function normalizeIss(iss) {
if (!iss) {
throw Errors.TOKEN_NO_ISSUER();
throw Errors.NO_ISSUER();
}
// 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"));
return obj;
} catch (e) {
var err = Errors.TOKEN_PARSE_ERROR(jwt);
var err = Errors.PARSE_ERROR(jwt);
err.details = e.message;
throw err;
}
@ -227,7 +230,7 @@ keyfetch.jwt.verify = async function (jwt, opts) {
opts = {};
}
var decoded;
var jws;
var exp;
var nbf;
var active;
@ -249,60 +252,68 @@ keyfetch.jwt.verify = async function (jwt, opts) {
}
var claims = opts.claims || {};
if (!jwt || "string" === typeof jwt) {
decoded = keyfetch.jwt.decode(jwt);
jws = keyfetch.jwt.decode(jwt);
} 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)) {
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)
if (
!Object.keys(claims).every(function (key) {
if (claims[key] === decoded.claims[key]) {
var failedClaims = Object.keys(claims)
.filter(function (key) {
if (claims[key] !== jws.claims[key]) {
return true;
}
})
) {
throw Errors.CLAIMS_MISMATCH(Object.keys(claims));
.map(function (key) {
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) {
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.TOKEN_EXPIRED(exp);
throw Errors.EXPIRED(exp);
}
}
nbf = decoded.claims.nbf;
nbf = jws.claims.nbf;
if (nbf) {
active = parseInt(nbf, 10) - Date.now() / 1000 <= 0;
if (!active) {
throw Errors.TOKEN_INACTIVE(nbf);
throw Errors.INACTIVE(nbf);
}
}
if (opts.jwks || opts.jwk) {
return overrideLookup(opts.jwks || [opts.jwk]);
}
var kid = decoded.header.kid;
var kid = jws.header.kid;
var iss;
var fetcher;
var fetchOne;
if (!opts.strategy || "oidc" === opts.strategy) {
iss = decoded.claims.iss;
iss = jws.claims.iss;
fetcher = keyfetch.oidcJwks;
fetchOne = keyfetch.oidcJwk;
} else if ("auth0" === opts.strategy || "well-known" === opts.strategy) {
iss = decoded.claims.iss;
iss = jws.claims.iss;
fetcher = keyfetch.wellKnownJwks;
fetchOne = keyfetch.wellKnownJwk;
} else {
@ -317,10 +328,10 @@ keyfetch.jwt.verify = async function (jwt, opts) {
return fetcher(iss).then(verifyAny);
function verifyOne(hit) {
if (true === keyfetch.jws.verify(decoded, hit)) {
return decoded;
if (true === keyfetch.jws.verify(jws, hit)) {
return jws;
}
throw Errors.TOKEN_INVALID_SIGNATURE();
throw Errors.BAD_SIGNATURE(jws.protected + "." + jws.payload + "." + jws.signature);
}
function verifyAny(hits) {
@ -330,20 +341,19 @@ keyfetch.jwt.verify = async function (jwt, opts) {
if (kid !== hit.jwk.kid && kid !== hit.thumbprint) {
return;
}
if (true === keyfetch.jws.verify(decoded, hit)) {
return true;
}
throw Errors.TOKEN_INVALID_SIGNATURE();
} else {
if (true === keyfetch.jws.verify(decoded, hit)) {
if (true === keyfetch.jws.verify(jws, hit)) {
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) {

View File

@ -19,30 +19,46 @@
* }} opts
* @returns {AuthError}
*/
function create(msg, { status = 401, code = "", details }) {
function create(old, msg, code, status, details) {
/** @type AuthError */
//@ts-ignore
var err = new Error(msg);
err.message = err.message;
err.status = status;
var err = new Error(old);
err.client_message = msg;
err.code = code;
err.status = status;
if (details) {
err.details = details;
}
err.source = "keyfetch";
err.toJSON = toJSON;
err.toString = toString;
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
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
// MALFORMED_JWT - 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
// INVALID_JWT - the token's properties don't meet requirements - iss, claims, sig, exp
var E_INVALID = "INVALID_JWT";
module.exports = {
@ -54,12 +70,20 @@ module.exports = {
* @param {string} msg
* @returns {AuthError}
*/
DEVELOPER_ERROR: function (msg) {
return create(msg, { status: 500, code: E_DEVELOPER });
DEVELOPER_ERROR: function (old, msg, details) {
return create(old, msg, E_DEVELOPER, 500, details);
},
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 });
BAD_GATEWAY: function (err) {
var msg =
"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}
*/
INSECURE_ISSUER: function (iss) {
var msg =
var old =
"'" + 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
* @returns {AuthError}
*/
TOKEN_PARSE_ERROR: function (jwt) {
var msg = "could not parse jwt: '" + jwt + "'";
return create(msg, { status: 400, code: E_MALFORMED });
PARSE_ERROR: function (jwt) {
var old = "could not parse jwt: '" + jwt + "'";
var msg = "The auth token is malformed.";
var details = ["jwt = " + JSON.stringify(jwt)];
return create(old, msg, E_MALFORMED, 400, details);
},
/**
* @param {string} iss
* @returns {AuthError}
*/
TOKEN_NO_ISSUER: function (iss) {
var msg = "'iss' is not defined";
return create(msg, { status: 400, code: E_MALFORMED });
NO_ISSUER: function (iss) {
var old = "'iss' is not defined";
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
* @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 });
EXPIRED: function (exp) {
var old = "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.";
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
* @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 });
INACTIVE: function (nbf) {
var old = "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.";
var details = ["jwt.claims.nbf = " + JSON.stringify(nbf)];
return create(old, msg, E_INVALID, 401, details);
},
/** @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 });
BAD_SIGNATURE: function (jwt) {
var old = "token signature verification was unsuccessful";
var msg = "The auth token did not pass verification because it is not properly signed.";
var details = ["jwt = " + JSON.stringify(jwt)];
return create(old, msg, E_INVALID, 401, details);
},
/** @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} kid
* @returns {AuthError}
*/
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
* @returns {AuthError}
*/
JWK_NOT_FOUND: function (id) {
var msg = "No JWK found by kid or thumbprint '" + id + "'";
return create(msg, { code: E_INVALID });
// TODO Distinguish between when it's a kid vs thumbprint.
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} */
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 });
NO_JWKWS_URI: function (url) {
var old = "Failed to retrieve openid configuration";
var msg =
'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
* @returns {AuthError}
*/
ISSUER_NOT_TRUSTED: function (iss) {
var msg = "token was issued by an untrusted issuer: '" + iss + "'";
return create(msg, { code: E_INVALID });
UNKNOWN_ISSUER: function (iss) {
var old = "token was issued by an untrusted issuer: '" + iss + "'";
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}
*/
CLAIMS_MISMATCH: function (claimNames) {
var msg = "token did not match on one or more authorization claims: '" + claimNames + "'";
return create(msg, { code: E_INVALID });
FAILED_CLAIMS: function (details, claimNames) {
var old = "token did not match on one or more authorization claims: '" + claimNames + "'";
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
if (require.main === module) {
console.info("| Name | Status | Message (truncated) |");
console.info("| ---- | ------ | ------------------- |");
console.info("| Hint | Code | Status | Message (truncated) |");
console.info("| ---- | ---- | ------ | ------------------- |");
Object.keys(module.exports).forEach(function (k) {
//@ts-ignore
var E = module.exports[k];
var e = E();
var e = E("test");
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)}... |`);
var msg = e.message.slice(0, 45);
var hint = k.toLowerCase().replace(/_/g, " ");
console.info(`| (${hint}) | ${code} | ${e.status} | ${msg}... |`);
});
console.log(Errors.MALFORMED_EXP());
console.log(JSON.stringify(Errors.MALFORMED_EXP(), null, 2));
}