Compare commits

...

16 Commits
v1.3.0 ... main

9 changed files with 772 additions and 321 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
}

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

@ -1,4 +1,4 @@
# keyfetch
# [keyfetch](https://git.rootprojects.org/root/keyfetch.js)
Lightweight support for fetching JWKs.
@ -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
@ -171,22 +182,23 @@ keyfetch.jwt.verify(jwt, { strategy: "oidc" }).then(function (verified) {
});
```
When used for authorization, it's important to specify which `issuers` are allowed
(otherwise anyone can create a valid token with whatever any claims they want).
When used for authorization, it's important to specify a limited set of trusted `issuers`. \
When using for federated authentication you may set `issuers = ["*"]` - but **DO NOT** trust claims such as `email` and `email_verified`.
If your authorization `claims` can be expressed as exact string matches, you can specify those too.
```js
keyfetch.jwt.verify(jwt, {
strategy: 'oidc'
, issuers: [ 'https://example.com/' ]
, claims: { role: 'admin', sub: 'abc', group: 'xyz' }
strategy: 'oidc',
issuers: [ 'https://example.com/' ],
//iss: 'https://example.com/',
claims: { role: 'admin', sub: 'abc', group: 'xyz' }
}).then(function (verified) {
```
- `strategy` may be `oidc` (default) , `auth0`, or a direct JWKs url.
- `issuers` must be a list of https urls (though http is allowed for things like Docker swarm)
- `issuers` must be a list of https urls (though http is allowed for things like Docker swarm), or '\*'
- `iss` is like `issuers`, but only one
- `claims` is an object with arbitrary keys (i.e. everything except for the standard `iat`, `exp`, `jti`, etc)
- `exp` may be set to `false` if you're validating on your own (i.e. allowing time drift leeway)
- `jwks` can be used to specify a list of allowed public key rather than fetching them (i.e. for offline unit tests)
@ -247,3 +259,50 @@ 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.
- `message` 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).
- `details` may be added to, but not subtracted from
| Hint | Code | Status | Message (truncated) |
| ----------------- | ------------- | ------ | ------------------------------------------------------ |
| bad gateway | BAD_GATEWAY | 502 | The auth token could not be verified because our se... |
| insecure issuer | MALFORMED_JWT | 400 | The auth token could not be verified because our se... |
| parse error | MALFORMED_JWT | 400 | The auth token could not be verified because it is ... |
| no issuer | MALFORMED_JWT | 400 | The auth token could not be verified because it doe... |
| malformed exp | MALFORMED_JWT | 400 | The auth token could not be verified because it's e... |
| expired | INVALID_JWT | 401 | The auth token is expired. To try again, go to the ... |
| inactive | INVALID_JWT | 401 | The auth token isn't valid yet. It's activation dat... |
| bad signature | INVALID_JWT | 401 | The auth token did not pass verification because it... |
| jwk not found old | INVALID_JWT | 401 | The auth token did not pass verification because ou... |
| jwk not found | INVALID_JWT | 401 | The auth token did not pass verification because ou... |
| no jwkws uri | INVALID_JWT | 401 | The auth token did not pass verification because it... |
| unknown issuer | INVALID_JWT | 401 | The auth token did not pass verification because it... |
| failed claims | INVALID_JWT | 401 | The auth token did not pass verification because it... |
# 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`)
- started using `let` and template strings (drops _really_ old node compat)
- 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

@ -25,29 +25,32 @@ keyfetch
});
/*global Promise*/
var keypairs = require("keypairs.js");
var keypairs = require("keypairs");
keypairs.generate().then(function (pair) {
var iss = "https://example.com/";
return Promise.all([
keypairs
.signJwt({
jwk: pair.private,
iss: "https://example.com/",
iss: iss,
sub: "mikey",
exp: "1h"
})
.then(function (jwt) {
return Promise.all([
keyfetch.jwt.verify(jwt, { jwk: pair.public }).then(function (verified) {
keyfetch.jwt.verify(jwt, { jwk: pair.public, iss: "*" }).then(function (verified) {
if (!(verified.claims && verified.claims.exp)) {
throw new Error("malformed decoded token");
}
}),
keyfetch.jwt.verify(keyfetch.jwt.decode(jwt), { jwk: pair.public }).then(function (verified) {
if (!(verified.claims && verified.claims.exp)) {
throw new Error("malformed decoded token");
}
}),
keyfetch.jwt.verify(jwt, { jwks: [pair.public] }),
keyfetch.jwt
.verify(keyfetch.jwt.decode(jwt), { jwk: pair.public, iss: iss })
.then(function (verified) {
if (!(verified.claims && verified.claims.exp)) {
throw new Error("malformed decoded token");
}
}),
keyfetch.jwt.verify(jwt, { jwks: [pair.public], issuers: [iss] }),
keyfetch.jwt.verify(jwt, {
jwk: pair.public,
issuers: ["https://example.com/"]
@ -118,7 +121,12 @@ keypairs.generate().then(function (pair) {
})
.then(function (jwt) {
return Promise.all([
keyfetch.jwt.verify(jwt, { jwk: pair.public }),
// test that the old behavior of defaulting to '*' still works
keyfetch.jwt
.verify(jwt, { jwk: pair.public })
.then(e("should have issued security warning about allow all by default"))
.catch(throwIfNotExpected),
keyfetch.jwt.verify(jwt, { jwk: pair.public, issuers: ["*"] }),
keyfetch.jwt.verify(jwt).then(e("should have an issuer")).catch(throwIfNotExpected),
keyfetch.jwt
.verify(jwt, {

View File

@ -2,8 +2,9 @@
var keyfetch = module.exports;
var promisify = require("util").promisify;
var requestAsync = promisify(require("@coolaj86/urequest"));
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,7 +12,19 @@ var maxcache = 3 * 24 * 60 * 60;
var staletime = 15 * 60;
var keyCache = {};
/*global Promise*/
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({ response: resp });
}
return resp;
}
function checkMinDefaultMax(opts, key, n, d, x) {
var i = opts[key];
if (!i && 0 !== i) {
@ -20,10 +33,12 @@ 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);
}
}
keyfetch._errors = Errors;
keyfetch._clear = function () {
keyCache = {};
};
@ -32,85 +47,73 @@ keyfetch.init = function (opts) {
maxcache = checkMinDefaultMax(opts, "maxcache", 1 * 60 * 60, maxcache, 31 * 24 * 60 * 60);
staletime = checkMinDefaultMax(opts, "staletime", 1 * 60, staletime, 31 * 24 * 60 * 60);
};
keyfetch._oidc = function (iss) {
return Promise.resolve().then(function () {
return requestAsync({
url: normalizeIss(iss) + "/.well-known/openid-configuration",
json: true
}).then(function (resp) {
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw new Error("Failed to retrieve openid configuration");
}
return oidcConf;
});
keyfetch._oidc = async function (iss) {
var url = normalizeIss(iss) + "/.well-known/openid-configuration";
var resp = await requestAsync({
url: url,
json: true
});
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw Errors.NO_JWKS_URI(url);
}
return oidcConf;
};
keyfetch._wellKnownJwks = function (iss) {
return Promise.resolve().then(function () {
return keyfetch._jwks(normalizeIss(iss) + "/.well-known/jwks.json");
});
keyfetch._wellKnownJwks = async function (iss) {
return keyfetch._jwks(normalizeIss(iss) + "/.well-known/jwks.json");
};
keyfetch._jwks = function (iss) {
return requestAsync({ url: iss, json: true }).then(function (resp) {
return Promise.all(
resp.body.keys.map(function (jwk) {
// EC keys have an x values, whereas RSA keys do not
var Keypairs = jwk.x ? Eckles : Rasha;
return Keypairs.thumbprint({ jwk: jwk }).then(function (thumbprint) {
return Keypairs.export({ jwk: jwk }).then(function (pem) {
var cacheable = {
jwk: jwk,
thumbprint: thumbprint,
pem: pem
};
return cacheable;
});
});
})
);
});
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
var Keypairs = jwk.x ? Eckles : Rasha;
var thumbprint = await Keypairs.thumbprint({ jwk: jwk });
var pem = await Keypairs.export({ jwk: jwk });
var cacheable = {
jwk: jwk,
thumbprint: thumbprint,
pem: pem
};
return cacheable;
})
);
};
keyfetch.jwks = function (jwkUrl) {
keyfetch.jwks = async function (jwkUrl) {
// TODO DRY up a bit
return keyfetch._jwks(jwkUrl).then(function (results) {
return Promise.all(
results.map(function (result) {
return keyfetch._setCache(result.jwk.iss || jwkUrl, result);
})
).then(function () {
// cacheable -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
});
});
var results = await keyfetch._jwks(jwkUrl);
await Promise.all(
results.map(async function (result) {
return keyfetch._setCache(result.jwk.iss || jwkUrl, result);
})
);
// cacheable -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
};
keyfetch.wellKnownJwks = function (iss) {
keyfetch.wellKnownJwks = async function (iss) {
// TODO DRY up a bit
return keyfetch._wellKnownJwks(iss).then(function (results) {
return Promise.all(
results.map(function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result);
})
).then(function () {
// result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
});
});
var results = await keyfetch._wellKnownJwks(iss);
await Promise.all(
results.map(async function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result);
})
);
// result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
};
keyfetch.oidcJwks = function (iss) {
return keyfetch._oidc(iss).then(function (oidcConf) {
// TODO DRY up a bit
return keyfetch._jwks(oidcConf.jwks_uri).then(function (results) {
return Promise.all(
results.map(function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result);
})
).then(function () {
// result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
});
});
});
keyfetch.oidcJwks = async function (iss) {
var oidcConf = await keyfetch._oidc(iss);
// TODO DRY up a bit
var results = await keyfetch._jwks(oidcConf.jwks_uri);
await Promise.all(
results.map(async function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result);
})
);
// result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
};
function checkId(id) {
return function (results) {
@ -120,59 +123,54 @@ 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;
};
}
keyfetch.oidcJwk = function (id, iss) {
return keyfetch._checkCache(id, iss).then(function (hit) {
if (hit) {
return hit;
}
return keyfetch.oidcJwks(iss).then(checkId(id));
});
keyfetch.oidcJwk = async function (id, iss) {
var hit = await keyfetch._checkCache(id, iss);
if (hit) {
return hit;
}
return keyfetch.oidcJwks(iss).then(checkId(id));
};
keyfetch.wellKnownJwk = function (id, iss) {
return keyfetch._checkCache(id, iss).then(function (hit) {
if (hit) {
return hit;
}
return keyfetch.wellKnownJwks(iss).then(checkId(id));
});
keyfetch.wellKnownJwk = async function (id, iss) {
var hit = await keyfetch._checkCache(id, iss);
if (hit) {
return hit;
}
return keyfetch.wellKnownJwks(iss).then(checkId(id));
};
keyfetch.jwk = function (id, jwksUrl) {
return keyfetch._checkCache(id, jwksUrl).then(function (hit) {
if (hit) {
return hit;
}
return keyfetch.jwks(jwksUrl).then(checkId(id));
});
keyfetch.jwk = async function (id, jwksUrl) {
var hit = await keyfetch._checkCache(id, jwksUrl);
if (hit) {
return hit;
}
return keyfetch.jwks(jwksUrl).then(checkId(id));
};
keyfetch._checkCache = function (id, iss) {
return Promise.resolve().then(function () {
// We cache by thumbprint and (kid + '@' + iss),
// so it's safe to check without appending the issuer
var hit = keyCache[id];
if (!hit) {
hit = keyCache[id + "@" + normalizeIss(iss)];
}
if (!hit) {
return null;
}
var now = Math.round(Date.now() / 1000);
var left = hit.expiresAt - now;
// not guarding number checks since we know that we
// set 'now' and 'expiresAt' correctly elsewhere
if (left > staletime) {
return JSON.parse(JSON.stringify(hit));
}
if (left > 0) {
return JSON.parse(JSON.stringify(hit));
}
keyfetch._checkCache = async function (id, iss) {
// We cache by thumbprint and (kid + '@' + iss),
// so it's safe to check without appending the issuer
var hit = keyCache[id];
if (!hit) {
hit = keyCache[id + "@" + normalizeIss(iss)];
}
if (!hit) {
return null;
});
}
var now = Math.round(Date.now() / 1000);
var left = hit.expiresAt - now;
// not guarding number checks since we know that we
// set 'now' and 'expiresAt' correctly elsewhere
if (left > staletime) {
return JSON.parse(JSON.stringify(hit));
}
if (left > 0) {
return JSON.parse(JSON.stringify(hit));
}
return null;
};
keyfetch._setCache = function (iss, cacheable) {
// force into a number
@ -197,155 +195,177 @@ keyfetch._setCache = function (iss, cacheable) {
};
function normalizeIss(iss) {
if (!iss) {
throw Errors.NO_ISSUER();
}
// We definitely don't want false negatives stemming
// from https://example.com vs https://example.com/
// 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.PARSE_ERROR(jwt);
err.details = e.message;
throw err;
}
};
keyfetch.jwt.verify = function (jwt, opts) {
keyfetch.jwt.verify = async function (jwt, opts) {
if (!opts) {
opts = {};
}
return Promise.resolve().then(function () {
var decoded;
var exp;
var nbf;
var active;
var issuers = opts.issuers || ["*"];
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 + "'");
}
} 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 + "'");
var jws;
var exp;
var nbf;
var active;
var now;
var then;
var issuers = opts.issuers || [];
if (opts.iss) {
issuers.push(opts.iss);
}
if (opts.claims && opts.claims.iss) {
issuers.push(opts.claims.iss);
}
if (!issuers.length) {
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/"
);
}
// TODO verify claims also?
}
var claims = opts.claims || {};
if (!jwt || "string" === typeof jwt) {
jws = keyfetch.jwt.decode(jwt);
} else {
jws = jwt;
}
if (!jws.claims.iss || !issuers.some(isTrustedIssuer(jws.claims.iss))) {
if (!(opts.jwk || opts.jwks)) {
throw Errors.UNKNOWN_ISSUER(jws.claims.iss || "");
}
}
// Note claims.iss validates more strictly than opts.issuers (requires exact match)
var failedClaims = Object.keys(claims)
.filter(function (key) {
if (claims[key] !== jws.claims[key]) {
return true;
}
})
.map(function (key) {
return "jwt.claims." + key + " = " + JSON.stringify(jws.claims[key]);
});
if (failedClaims.length) {
throw Errors.FAILED_CLAIMS(failedClaims, Object.keys(claims));
}
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.EXPIRED(exp);
}
}
nbf = jws.claims.nbf;
if (nbf) {
active = parseInt(nbf, 10) - Date.now() / 1000 <= 0;
if (!active) {
throw Errors.INACTIVE(nbf);
}
}
if (opts.jwks || opts.jwk) {
return overrideLookup(opts.jwks || [opts.jwk]);
}
var kid = jws.header.kid;
var iss;
var fetcher;
var fetchOne;
if (!opts.strategy || "oidc" === opts.strategy) {
iss = jws.claims.iss;
fetcher = keyfetch.oidcJwks;
fetchOne = keyfetch.oidcJwk;
} else if ("auth0" === opts.strategy || "well-known" === opts.strategy) {
iss = jws.claims.iss;
fetcher = keyfetch.wellKnownJwks;
fetchOne = keyfetch.wellKnownJwk;
} else {
iss = opts.strategy;
fetcher = keyfetch.jwks;
fetchOne = keyfetch.jwk;
}
if (kid) {
return fetchOne(kid, iss).then(verifyOne); //.catch(fetchAny);
}
return fetcher(iss).then(verifyAny);
function verifyOne(hit) {
if (true === keyfetch.jws.verify(jws, hit)) {
return jws;
}
throw Errors.BAD_SIGNATURE(jws.protected + "." + jws.payload + "." + jws.signature);
}
function verifyAny(hits) {
if (
!Object.keys(claims).every(function (key) {
if (claims[key] === decoded.claims[key]) {
hits.some(function (hit) {
if (kid) {
if (kid !== hit.jwk.kid && kid !== hit.thumbprint) {
return;
}
if (true === keyfetch.jws.verify(jws, hit)) {
return true;
}
throw Errors.BAD_SIGNATURE();
}
if (true === keyfetch.jws.verify(jws, hit)) {
return true;
}
})
) {
throw new Error("token did not match on one or more authorization claims: '" + Object.keys(claims) + "'");
return jws;
}
throw Errors.JWK_NOT_FOUND_OLD(kid);
}
active = (opts.exp || 0) + parseInt(exp, 10) - Date.now() / 1000 > 0;
if (!active) {
// 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 (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 + "'");
}
}
if (opts.jwks || opts.jwk) {
return overrideLookup(opts.jwks || [opts.jwk]);
}
var kid = decoded.header.kid;
var iss;
var fetcher;
var fetchOne;
if (!opts.strategy || "oidc" === opts.strategy) {
iss = decoded.claims.iss;
fetcher = keyfetch.oidcJwks;
fetchOne = keyfetch.oidcJwk;
} else if ("auth0" === opts.strategy || "well-known" === opts.strategy) {
iss = decoded.claims.iss;
fetcher = keyfetch.wellKnownJwks;
fetchOne = keyfetch.wellKnownJwk;
} else {
iss = opts.strategy;
fetcher = keyfetch.jwks;
fetchOne = keyfetch.jwk;
}
var p;
if (kid) {
p = fetchOne(kid, iss).then(verifyOne); //.catch(fetchAny);
} else {
p = fetcher(iss).then(verifyAny);
}
return p;
function verifyOne(hit) {
if (true === keyfetch.jws.verify(decoded, hit)) {
return decoded;
}
throw new Error("token signature verification was unsuccessful");
}
function verifyAny(hits) {
if (
hits.some(function (hit) {
if (kid) {
if (kid !== hit.jwk.kid && kid !== hit.thumbprint) {
return;
}
if (true === keyfetch.jws.verify(decoded, hit)) {
return true;
}
throw new Error("token signature verification was unsuccessful");
} else {
if (true === keyfetch.jws.verify(decoded, hit)) {
return true;
}
}
})
) {
return decoded;
}
throw new Error("Retrieved a list of keys, but none of them matched the 'kid' (key id) of the token.");
}
function overrideLookup(jwks) {
return Promise.all(
jwks.map(function (jwk) {
var Keypairs = jwk.x ? Eckles : Rasha;
return Keypairs.export({ jwk: jwk }).then(function (pem) {
return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) {
return { jwk: jwk, pem: pem, thumbprint: thumb };
});
});
})
).then(verifyAny);
}
});
function overrideLookup(jwks) {
return Promise.all(
jwks.map(async function (jwk) {
var Keypairs = jwk.x ? Eckles : Rasha;
var pem = await Keypairs.export({ jwk: jwk });
var thumb = await Keypairs.thumbprint({ jwk: jwk });
return { jwk: jwk, pem: pem, thumbprint: thumb };
})
).then(verifyAny);
}
};
keyfetch.jws = {};
keyfetch.jws.verify = function (jws, pub) {
@ -362,11 +382,10 @@ keyfetch._decode = function (jwt) {
var obj = keyfetch.jwt.decode(jwt);
return { header: obj.header, payload: obj.claims, signature: obj.signature };
};
keyfetch.verify = function (opts) {
keyfetch.verify = async function (opts) {
var jwt = opts.jwt;
return keyfetch.jwt.verify(jwt, opts).then(function (obj) {
return { header: obj.header, payload: obj.claims, signature: obj.signature };
});
var obj = await keyfetch.jwt.verify(jwt, opts);
return { header: obj.header, payload: obj.claims, signature: obj.signature };
};
function ecdsaJoseSigToAsn1Sig(header, b64sig) {

270
lib/errors.js Normal file
View File

@ -0,0 +1,270 @@
"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(old, msg, code, status, details) {
/** @type AuthError */
//@ts-ignore
let err = new Error(msg);
err.message = msg;
err._old_message = old;
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.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_JWT - the token could not be verified - not parsable, missing claims, etc
var E_MALFORMED = "MALFORMED_JWT";
// INVALID_JWT - 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 (old, msg, details) {
return create(old, msg || old, E_DEVELOPER, 500, details);
},
BAD_GATEWAY: function (err) {
var msg =
"The auth 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, details);
},
//
// MALFORMED_TOKEN (dev / client)
//
/**
* @param {string} iss
* @returns {AuthError}
*/
INSECURE_ISSUER: function (iss) {
var old =
"'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing. (iss)";
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 auth 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}
*/
PARSE_ERROR: function (jwt) {
var old = "could not parse jwt: '" + jwt + "'";
var msg = "The auth token could not be verified because it is malformed.";
var details = ["jwt = " + JSON.stringify(jwt)];
return create(old, msg, E_MALFORMED, 400, details);
},
/**
* @param {string} iss
* @returns {AuthError}
*/
NO_ISSUER: function (iss) {
var old = "'iss' is not defined";
var msg = 'The auth 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);
},
//
// INVALID_TOKEN (dev / client)
//
/**
* @param {number} exp
* @returns {AuthError}
*/
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}
*/
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} */
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);
},
/**
* @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) {
// 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} */
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}
*/
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>} details
* @returns {AuthError}
*/
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) {
let maxWidth = 54;
let header = ["Hint", "Code", "Status", "Message (truncated)"];
let widths = header.map(function (v) {
return Math.min(maxWidth, String(v).length);
});
let rows = [];
Object.keys(module.exports).forEach(function (k) {
//@ts-ignore
var E = module.exports[k];
var e = E("test");
var code = e.code;
var msg = e.message;
var hint = k.toLowerCase().replace(/_/g, " ");
widths[0] = Math.max(widths[0], String(hint).length);
widths[1] = Math.max(widths[1], String(code).length);
widths[2] = Math.max(widths[2], String(e.status).length);
widths[3] = Math.min(maxWidth, Math.max(widths[3], String(msg).length));
rows.push([hint, code, e.status, msg]);
});
rows.forEach(function (cols, i) {
let cells = cols.map(function (col, i) {
if (col.length > maxWidth) {
col = col.slice(0, maxWidth - 3);
col += "...";
}
return String(col).padEnd(widths[i], " ");
});
let out = `| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${cells[3].slice(0, widths[3])} |`;
//out = out.replace(/\| /g, " ").replace(/\|/g, "");
console.info(out);
if (i === 0) {
cells = cols.map(function (col, i) {
return "-".padEnd(widths[i], "-");
});
console.info(`| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${cells[3]} |`);
}
});
console.log();
console.log(Errors.MALFORMED_EXP());
console.log();
console.log(JSON.stringify(Errors.MALFORMED_EXP(), null, 2));
}

97
package-lock.json generated
View File

@ -1,23 +1,82 @@
{
"name": "keyfetch",
"version": "1.1.8",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@coolaj86/urequest": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.7.tgz",
"integrity": "sha512-PPrVYra9aWvZjSCKl/x1pJ9ZpXda1652oJrPBYy5rQumJJMkmTBN3ux+sK2xAUwVvv2wnewDlaQaHLxLwSHnIA=="
"name": "keyfetch",
"version": "3.0.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "keyfetch",
"version": "3.0.2",
"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=="
},
"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

@ -1,36 +1,39 @@
{
"name": "keyfetch",
"version": "1.2.1",
"description": "Lightweight support for fetching JWKs.",
"homepage": "https://git.coolaj86.com/coolaj86/keyfetch.js",
"main": "keyfetch.js",
"files": [],
"dependencies": {
"@coolaj86/urequest": "^1.3.7",
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},
"devDependencies": {},
"scripts": {
"test": "node keyfetch-test.js"
},
"repository": {
"type": "git",
"url": "https://git.coolaj86.com/coolaj86/keyfetch.js.git"
},
"keywords": [
"jwks",
"jwk",
"jwt",
"auth0",
"pem",
"RSA",
"EC",
"ECDSA",
"OIDC",
"well-known"
],
"author": "AJ ONeal <solderjs@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0"
"name": "keyfetch",
"version": "3.0.2",
"description": "Lightweight support for fetching JWKs.",
"homepage": "https://git.rootprojects.org/root/keyfetch.js",
"main": "keyfetch.js",
"files": [
"lib"
],
"dependencies": {
"@root/request": "^1.8.0",
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},
"devDependencies": {
"keypairs": "^1.2.14"
},
"scripts": {
"test": "node keyfetch-test.js"
},
"repository": {
"type": "git",
"url": "https://git.rootprojects.org/root/keyfetch.js.git"
},
"keywords": [
"jwks",
"jwk",
"jwt",
"auth0",
"pem",
"RSA",
"EC",
"ECDSA",
"OIDC",
"well-known"
],
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0"
}