Compare commits

..

1 Commits
main ... v1.3.0

Author SHA1 Message Date
d1acc8d635 use updated @root/request 2020-04-08 16:07:34 -04:00
9 changed files with 318 additions and 771 deletions

View File

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

View File

@ -1,11 +0,0 @@
# 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](https://git.rootprojects.org/root/keyfetch.js) # keyfetch
Lightweight support for fetching JWKs. Lightweight support for fetching JWKs.
@ -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, Okta) - [x] .well-known/jwks.json (Auth0)
- [x] Other JWKs URLs - [x] Other JWKs URLs
Crypto Support Crypto Support
@ -19,19 +19,8 @@ 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
@ -182,23 +171,22 @@ keyfetch.jwt.verify(jwt, { strategy: "oidc" }).then(function (verified) {
}); });
``` ```
When used for authorization, it's important to specify a limited set of trusted `issuers`. \ When used for authorization, it's important to specify which `issuers` are allowed
When using for federated authentication you may set `issuers = ["*"]` - but **DO NOT** trust claims such as `email` and `email_verified`. (otherwise anyone can create a valid token with whatever any claims they want).
If your authorization `claims` can be expressed as exact string matches, you can specify those too. If your authorization `claims` can be expressed as exact string matches, you can specify those too.
```js ```js
keyfetch.jwt.verify(jwt, { keyfetch.jwt.verify(jwt, {
strategy: 'oidc', strategy: 'oidc'
issuers: [ 'https://example.com/' ], , issuers: [ 'https://example.com/' ]
//iss: 'https://example.com/', , claims: { role: 'admin', sub: 'abc', group: 'xyz' }
claims: { role: 'admin', sub: 'abc', group: 'xyz' }
}).then(function (verified) { }).then(function (verified) {
``` ```
- `strategy` may be `oidc` (default) , `auth0`, or a direct JWKs url. - `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), or '\*' - `issuers` must be a list of https urls (though http is allowed for things like Docker swarm)
- `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) - `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) - `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) - `jwks` can be used to specify a list of allowed public key rather than fetching them (i.e. for offline unit tests)
@ -259,50 +247,3 @@ 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.
- `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,32 +25,29 @@ keyfetch
}); });
/*global Promise*/ /*global Promise*/
var keypairs = require("keypairs"); var keypairs = require("keypairs.js");
keypairs.generate().then(function (pair) { keypairs.generate().then(function (pair) {
var iss = "https://example.com/";
return Promise.all([ return Promise.all([
keypairs keypairs
.signJwt({ .signJwt({
jwk: pair.private, jwk: pair.private,
iss: iss, iss: "https://example.com/",
sub: "mikey", sub: "mikey",
exp: "1h" exp: "1h"
}) })
.then(function (jwt) { .then(function (jwt) {
return Promise.all([ return Promise.all([
keyfetch.jwt.verify(jwt, { jwk: pair.public, iss: "*" }).then(function (verified) { keyfetch.jwt.verify(jwt, { jwk: pair.public }).then(function (verified) {
if (!(verified.claims && verified.claims.exp)) { if (!(verified.claims && verified.claims.exp)) {
throw new Error("malformed decoded token"); throw new Error("malformed decoded token");
} }
}), }),
keyfetch.jwt keyfetch.jwt.verify(keyfetch.jwt.decode(jwt), { jwk: pair.public }).then(function (verified) {
.verify(keyfetch.jwt.decode(jwt), { jwk: pair.public, iss: iss }) if (!(verified.claims && verified.claims.exp)) {
.then(function (verified) { throw new Error("malformed decoded token");
if (!(verified.claims && verified.claims.exp)) { }
throw new Error("malformed decoded token"); }),
} keyfetch.jwt.verify(jwt, { jwks: [pair.public] }),
}),
keyfetch.jwt.verify(jwt, { jwks: [pair.public], issuers: [iss] }),
keyfetch.jwt.verify(jwt, { keyfetch.jwt.verify(jwt, {
jwk: pair.public, jwk: pair.public,
issuers: ["https://example.com/"] issuers: ["https://example.com/"]
@ -121,12 +118,7 @@ keypairs.generate().then(function (pair) {
}) })
.then(function (jwt) { .then(function (jwt) {
return Promise.all([ return Promise.all([
// test that the old behavior of defaulting to '*' still works keyfetch.jwt.verify(jwt, { jwk: pair.public }),
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).then(e("should have an issuer")).catch(throwIfNotExpected),
keyfetch.jwt keyfetch.jwt
.verify(jwt, { .verify(jwt, {

View File

@ -2,9 +2,7 @@
var keyfetch = module.exports; var keyfetch = module.exports;
var request = require("@root/request").defaults({ var request = require("@root/request");
userAgent: "keyfetch/v2.1.0"
});
var Rasha = require("rasha"); var Rasha = require("rasha");
var Eckles = require("eckles"); var Eckles = require("eckles");
var mincache = 1 * 60 * 60; var mincache = 1 * 60 * 60;
@ -12,19 +10,7 @@ var maxcache = 3 * 24 * 60 * 60;
var staletime = 15 * 60; var staletime = 15 * 60;
var keyCache = {}; var keyCache = {};
var Errors = require("./lib/errors.js"); /*global Promise*/
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) { function checkMinDefaultMax(opts, key, n, d, x) {
var i = opts[key]; var i = opts[key];
if (!i && 0 !== i) { if (!i && 0 !== i) {
@ -33,12 +19,10 @@ function checkMinDefaultMax(opts, key, n, d, x) {
if (i >= n && i >= x) { if (i >= n && i >= x) {
return parseInt(i, 10); return parseInt(i, 10);
} else { } else {
throw Errors.DEVELOPER_ERROR("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i); throw new Error("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i);
} }
} }
keyfetch._errors = Errors;
keyfetch._clear = function () { keyfetch._clear = function () {
keyCache = {}; keyCache = {};
}; };
@ -47,73 +31,85 @@ keyfetch.init = function (opts) {
maxcache = checkMinDefaultMax(opts, "maxcache", 1 * 60 * 60, maxcache, 31 * 24 * 60 * 60); maxcache = checkMinDefaultMax(opts, "maxcache", 1 * 60 * 60, maxcache, 31 * 24 * 60 * 60);
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 = function (iss) {
var url = normalizeIss(iss) + "/.well-known/openid-configuration"; return Promise.resolve().then(function () {
var resp = await requestAsync({ return request({
url: url, url: normalizeIss(iss) + "/.well-known/openid-configuration",
json: true json: true
}).then(function (resp) {
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw new Error("Failed to retrieve openid configuration");
}
return oidcConf;
});
}); });
var oidcConf = resp.body;
if (!oidcConf.jwks_uri) {
throw Errors.NO_JWKS_URI(url);
}
return oidcConf;
}; };
keyfetch._wellKnownJwks = async function (iss) { keyfetch._wellKnownJwks = function (iss) {
return keyfetch._jwks(normalizeIss(iss) + "/.well-known/jwks.json"); return Promise.resolve().then(function () {
return keyfetch._jwks(normalizeIss(iss) + "/.well-known/jwks.json");
});
}; };
keyfetch._jwks = async function (iss) { keyfetch._jwks = function (iss) {
var resp = await requestAsync({ url: iss, json: true }); return request({ url: iss, json: true }).then(function (resp) {
return Promise.all(
return Promise.all( resp.body.keys.map(function (jwk) {
resp.body.keys.map(async function (jwk) { // EC keys have an x values, whereas RSA keys do not
// EC keys have an x values, whereas RSA keys do not var Keypairs = jwk.x ? Eckles : Rasha;
var Keypairs = jwk.x ? Eckles : Rasha; return Keypairs.thumbprint({ jwk: jwk }).then(function (thumbprint) {
var thumbprint = await Keypairs.thumbprint({ jwk: jwk }); return Keypairs.export({ jwk: jwk }).then(function (pem) {
var pem = await Keypairs.export({ jwk: jwk }); var cacheable = {
var cacheable = { jwk: jwk,
jwk: jwk, thumbprint: thumbprint,
thumbprint: thumbprint, pem: pem
pem: pem };
}; return cacheable;
return cacheable; });
}) });
); })
);
});
}; };
keyfetch.jwks = async function (jwkUrl) { keyfetch.jwks = function (jwkUrl) {
// TODO DRY up a bit // TODO DRY up a bit
var results = await keyfetch._jwks(jwkUrl); return keyfetch._jwks(jwkUrl).then(function (results) {
await Promise.all( return Promise.all(
results.map(async function (result) { results.map(function (result) {
return keyfetch._setCache(result.jwk.iss || jwkUrl, result); return keyfetch._setCache(result.jwk.iss || jwkUrl, result);
}) })
); ).then(function () {
// cacheable -> hit (keep original externally immutable) // cacheable -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results)); return JSON.parse(JSON.stringify(results));
});
});
}; };
keyfetch.wellKnownJwks = async function (iss) { keyfetch.wellKnownJwks = function (iss) {
// TODO DRY up a bit // TODO DRY up a bit
var results = await keyfetch._wellKnownJwks(iss); return keyfetch._wellKnownJwks(iss).then(function (results) {
await Promise.all( return Promise.all(
results.map(async function (result) { results.map(function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result); return keyfetch._setCache(result.jwk.iss || iss, result);
}) })
); ).then(function () {
// result -> hit (keep original externally immutable) // result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results)); return JSON.parse(JSON.stringify(results));
});
});
}; };
keyfetch.oidcJwks = async function (iss) { keyfetch.oidcJwks = function (iss) {
var oidcConf = await keyfetch._oidc(iss); return keyfetch._oidc(iss).then(function (oidcConf) {
// TODO DRY up a bit // TODO DRY up a bit
var results = await keyfetch._jwks(oidcConf.jwks_uri); return keyfetch._jwks(oidcConf.jwks_uri).then(function (results) {
await Promise.all( return Promise.all(
results.map(async function (result) { results.map(function (result) {
return keyfetch._setCache(result.jwk.iss || iss, result); return keyfetch._setCache(result.jwk.iss || iss, result);
}) })
); ).then(function () {
// result -> hit (keep original externally immutable) // result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results)); return JSON.parse(JSON.stringify(results));
});
});
});
}; };
function checkId(id) { function checkId(id) {
return function (results) { return function (results) {
@ -123,54 +119,59 @@ function checkId(id) {
})[0]; })[0];
if (!result) { if (!result) {
throw Errors.JWK_NOT_FOUND(id); throw new Error("No JWK found by kid or thumbprint '" + id + "'");
} }
return result; return result;
}; };
} }
keyfetch.oidcJwk = async function (id, iss) { keyfetch.oidcJwk = function (id, iss) {
var hit = await keyfetch._checkCache(id, iss); return keyfetch._checkCache(id, iss).then(function (hit) {
if (hit) { if (hit) {
return hit; return hit;
} }
return keyfetch.oidcJwks(iss).then(checkId(id)); return keyfetch.oidcJwks(iss).then(checkId(id));
});
}; };
keyfetch.wellKnownJwk = async function (id, iss) { keyfetch.wellKnownJwk = function (id, iss) {
var hit = await keyfetch._checkCache(id, iss); return keyfetch._checkCache(id, iss).then(function (hit) {
if (hit) { if (hit) {
return hit; return hit;
} }
return keyfetch.wellKnownJwks(iss).then(checkId(id)); return keyfetch.wellKnownJwks(iss).then(checkId(id));
});
}; };
keyfetch.jwk = async function (id, jwksUrl) { keyfetch.jwk = function (id, jwksUrl) {
var hit = await keyfetch._checkCache(id, jwksUrl); return keyfetch._checkCache(id, jwksUrl).then(function (hit) {
if (hit) { if (hit) {
return hit; return hit;
} }
return keyfetch.jwks(jwksUrl).then(checkId(id)); return keyfetch.jwks(jwksUrl).then(checkId(id));
});
}; };
keyfetch._checkCache = async function (id, iss) { keyfetch._checkCache = function (id, iss) {
// We cache by thumbprint and (kid + '@' + iss), return Promise.resolve().then(function () {
// so it's safe to check without appending the issuer // We cache by thumbprint and (kid + '@' + iss),
var hit = keyCache[id]; // so it's safe to check without appending the issuer
if (!hit) { var hit = keyCache[id];
hit = keyCache[id + "@" + normalizeIss(iss)]; if (!hit) {
} hit = keyCache[id + "@" + normalizeIss(iss)];
if (!hit) { }
return null; if (!hit) {
} return null;
}
var now = Math.round(Date.now() / 1000); var now = Math.round(Date.now() / 1000);
var left = hit.expiresAt - now; var left = hit.expiresAt - now;
// not guarding number checks since we know that we // not guarding number checks since we know that we
// set 'now' and 'expiresAt' correctly elsewhere // set 'now' and 'expiresAt' correctly elsewhere
if (left > staletime) { if (left > staletime) {
return JSON.parse(JSON.stringify(hit)); return JSON.parse(JSON.stringify(hit));
} }
if (left > 0) { if (left > 0) {
return JSON.parse(JSON.stringify(hit)); return JSON.parse(JSON.stringify(hit));
} }
return null; return null;
});
}; };
keyfetch._setCache = function (iss, cacheable) { keyfetch._setCache = function (iss, cacheable) {
// force into a number // force into a number
@ -195,177 +196,155 @@ keyfetch._setCache = function (iss, cacheable) {
}; };
function normalizeIss(iss) { function normalizeIss(iss) {
if (!iss) {
throw Errors.NO_ISSUER();
}
// We definitely don't want false negatives stemming // We definitely don't want false negatives stemming
// from https://example.com vs https://example.com/ // from https://example.com vs https://example.com/
// We also don't want to allow insecure issuers // We also don't want to allow insecure issuers
if (/^http:/.test(iss) && !process.env.KEYFETCH_ALLOW_INSECURE_HTTP) { if (/^http:/.test(iss) && !process.env.KEYFETCH_ALLOW_INSECURE_HTTP) {
// note, we wrap some things in promises just so we can throw here // note, we wrap some things in promises just so we can throw here
throw Errors.INSECURE_ISSUER(iss); throw new Error(
"'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing."
);
} }
return iss.replace(/\/$/, ""); return iss.replace(/\/$/, "");
} }
keyfetch.jwt = {}; keyfetch.jwt = {};
keyfetch.jwt.decode = function (jwt) { keyfetch.jwt.decode = function (jwt) {
try { var parts = jwt.split(".");
var parts = jwt.split("."); // JWS
// JWS var obj = {
var obj = { protected: parts[0], payload: parts[1], signature: parts[2] }; protected: parts[0],
// JWT payload: parts[1],
obj.header = JSON.parse(Buffer.from(obj.protected, "base64")); signature: parts[2]
obj.claims = JSON.parse(Buffer.from(obj.payload, "base64")); };
return obj; // JWT
} catch (e) { obj.header = JSON.parse(Buffer.from(obj.protected, "base64"));
var err = Errors.PARSE_ERROR(jwt); obj.claims = JSON.parse(Buffer.from(obj.payload, "base64"));
err.details = e.message; return obj;
throw err;
}
}; };
keyfetch.jwt.verify = async function (jwt, opts) { keyfetch.jwt.verify = function (jwt, opts) {
if (!opts) { if (!opts) {
opts = {}; opts = {};
} }
return Promise.resolve().then(function () {
var jws; var decoded;
var exp; var exp;
var nbf; var nbf;
var active; var active;
var now; var issuers = opts.issuers || ["*"];
var then; var claims = opts.claims || {};
var issuers = opts.issuers || []; if (!jwt || "string" === typeof jwt) {
if (opts.iss) { try {
issuers.push(opts.iss); decoded = keyfetch.jwt.decode(jwt);
} } catch (e) {
if (opts.claims && opts.claims.iss) { throw new Error("could not parse jwt: '" + jwt + "'");
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/"
);
}
}
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;
} }
}) } else {
.map(function (key) { decoded = jwt;
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); exp = decoded.claims.exp;
active = then - now / 1000 > 0; nbf = decoded.claims.nbf;
// expiration was on the token or, if not, such a token is not allowed
if (!active) { if (!issuers.some(isTrustedIssuer(decoded.claims.iss))) {
throw Errors.EXPIRED(exp); throw new Error("token was issued by an untrusted issuer: '" + decoded.claims.iss + "'");
} }
} // TODO verify claims also?
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 ( if (
hits.some(function (hit) { !Object.keys(claims).every(function (key) {
if (kid) { if (claims[key] === decoded.claims[key]) {
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; return true;
} }
}) })
) { ) {
return jws; throw new Error("token did not match on one or more authorization claims: '" + Object.keys(claims) + "'");
} }
throw Errors.JWK_NOT_FOUND_OLD(kid);
}
function overrideLookup(jwks) { active = (opts.exp || 0) + parseInt(exp, 10) - Date.now() / 1000 > 0;
return Promise.all( if (!active) {
jwks.map(async function (jwk) { // expiration was on the token or, if not, such a token is not allowed
var Keypairs = jwk.x ? Eckles : Rasha; if (exp || false !== opts.exp) {
var pem = await Keypairs.export({ jwk: jwk }); throw new Error("token's 'exp' has passed or could not parsed: '" + exp + "'");
var thumb = await Keypairs.thumbprint({ jwk: jwk }); }
return { jwk: jwk, pem: pem, thumbprint: thumb }; }
}) if (nbf) {
).then(verifyAny); 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);
}
});
}; };
keyfetch.jws = {}; keyfetch.jws = {};
keyfetch.jws.verify = function (jws, pub) { keyfetch.jws.verify = function (jws, pub) {
@ -382,10 +361,11 @@ keyfetch._decode = function (jwt) {
var obj = keyfetch.jwt.decode(jwt); var obj = keyfetch.jwt.decode(jwt);
return { header: obj.header, payload: obj.claims, signature: obj.signature }; return { header: obj.header, payload: obj.claims, signature: obj.signature };
}; };
keyfetch.verify = async function (opts) { keyfetch.verify = function (opts) {
var jwt = opts.jwt; var jwt = opts.jwt;
var obj = await keyfetch.jwt.verify(jwt, opts); return keyfetch.jwt.verify(jwt, opts).then(function (obj) {
return { header: obj.header, payload: obj.claims, signature: obj.signature }; return { header: obj.header, payload: obj.claims, signature: obj.signature };
});
}; };
function ecdsaJoseSigToAsn1Sig(header, b64sig) { function ecdsaJoseSigToAsn1Sig(header, b64sig) {

View File

@ -1,270 +0,0 @@
"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,82 +1,23 @@
{ {
"name": "keyfetch", "name": "keyfetch",
"version": "3.0.2", "version": "1.3.0",
"lockfileVersion": 2, "lockfileVersion": 1,
"requires": true, "requires": true,
"packages": { "dependencies": {
"": { "@root/request": {
"name": "keyfetch", "version": "1.5.0",
"version": "3.0.2", "resolved": "https://registry.npmjs.org/@root/request/-/request-1.5.0.tgz",
"license": "MPL-2.0", "integrity": "sha512-J9RUIwVU99/cOVuDVYlNpr4G0A1/3ZxhCXIRiTZzu8RntOnb0lmDBMckhaus5ry9x/dBqJKDplFIgwHbLi6rLA=="
"dependencies": {
"@root/request": "^1.8.0",
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},
"devDependencies": {
"keypairs": "^1.2.14"
}
},
"node_modules/@root/request": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.8.0.tgz",
"integrity": "sha512-HufCvoTwqR30OyKSjwg28W5QCUpypSJZpOYcJbC9PME5kI6cOYsccYs/6bXfsuEoarz8+YwBDrsuM1UdBMxMLw=="
},
"node_modules/eckles": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz",
"integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA==",
"bin": {
"eckles": "bin/eckles.js"
}
},
"node_modules/keypairs": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.14.tgz",
"integrity": "sha512-ZoZfZMygyB0QcjSlz7Rh6wT2CJasYEHBPETtmHZEfxuJd7bnsOG5AdtPZqHZBT+hoHvuWCp/4y8VmvTvH0Y9uA==",
"dev": true,
"dependencies": {
"eckles": "^1.4.1",
"rasha": "^1.2.4"
},
"bin": {
"keypairs-install": "bin/keypairs.js"
}
},
"node_modules/rasha": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz",
"integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q==",
"bin": {
"rasha": "bin/rasha.js"
}
}
}, },
"dependencies": { "eckles": {
"@root/request": { "version": "1.4.1",
"version": "1.8.0", "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.8.0.tgz", "integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA=="
"integrity": "sha512-HufCvoTwqR30OyKSjwg28W5QCUpypSJZpOYcJbC9PME5kI6cOYsccYs/6bXfsuEoarz8+YwBDrsuM1UdBMxMLw==" },
}, "rasha": {
"eckles": { "version": "1.2.4",
"version": "1.4.1", "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz",
"resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz", "integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q=="
"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,39 +1,35 @@
{ {
"name": "keyfetch", "name": "keyfetch",
"version": "3.0.2", "version": "1.3.0",
"description": "Lightweight support for fetching JWKs.", "description": "Lightweight support for fetching JWKs.",
"homepage": "https://git.rootprojects.org/root/keyfetch.js", "homepage": "https://git.coolaj86.com/coolaj86/keyfetch.js",
"main": "keyfetch.js", "main": "keyfetch.js",
"files": [ "files": [],
"lib" "dependencies": {
], "@root/request": "^1.5.0",
"dependencies": { "eckles": "^1.4.1",
"@root/request": "^1.8.0", "rasha": "^1.2.4"
"eckles": "^1.4.1", },
"rasha": "^1.2.4" "devDependencies": {},
}, "scripts": {
"devDependencies": { "test": "node keyfetch-test.js"
"keypairs": "^1.2.14" },
}, "repository": {
"scripts": { "type": "git",
"test": "node keyfetch-test.js" "url": "https://git.coolaj86.com/coolaj86/keyfetch.js.git"
}, },
"repository": { "keywords": [
"type": "git", "jwks",
"url": "https://git.rootprojects.org/root/keyfetch.js.git" "jwk",
}, "jwt",
"keywords": [ "auth0",
"jwks", "pem",
"jwk", "RSA",
"jwt", "EC",
"auth0", "ECDSA",
"pem", "OIDC",
"RSA", "well-known"
"EC", ],
"ECDSA", "author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"OIDC", "license": "MPL-2.0"
"well-known"
],
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0"
} }