Compare commits

...

5 Commits

5 changed files with 645 additions and 351 deletions

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"bracketSpacing": true,
"printWidth": 120,
"tabWidth": 4,
"trailingComma": "none",
"useTabs": false
}

178
README.md
View File

@ -9,17 +9,17 @@ Fetches JSON native JWKs and exposes them as PEMs that can be consumed by the `j
Works great for Works great for
* [x] `jsonwebtoken` (Auth0) - [x] `jsonwebtoken` (Auth0)
* [x] OIDC (OpenID Connect) - [x] OIDC (OpenID Connect)
* [x] .well-known/jwks.json (Auth0) - [x] .well-known/jwks.json (Auth0)
* [x] Other JWKs URLs - [x] Other JWKs URLs
Crypto Support 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)
* [ ] esoteric variants (excluded to keep the code featherweight and secure) - [ ] esoteric variants (excluded to keep the code featherweight and secure)
# Install # Install
@ -32,64 +32,71 @@ npm install --save keyfetch
Retrieve a key list of keys: Retrieve a key list of keys:
```js ```js
var keyfetch = require('keyfetch'); var keyfetch = require("keyfetch");
keyfetch.oidcJwks("https://example.com/").then(function (results) { keyfetch.oidcJwks("https://example.com/").then(function (results) {
results.forEach(function (result) { results.forEach(function (result) {
console.log(result.jwk); console.log(result.jwk);
console.log(result.thumprint); console.log(result.thumprint);
console.log(result.pem); console.log(result.pem);
}); });
}); });
``` ```
Quick JWT verification: Quick JWT verification (for authentication):
```js ```js
var keyfetch = require('keyfetch'); var keyfetch = require("keyfetch");
var jwt = '...'; var jwt = "...";
keyfetch.verify({ jwt: jwt }).then(function (decoded) { keyfetch.jwt.verify(jwt).then(function (decoded) {
console.log(decoded); console.log(decoded);
});
```
JWT verification (for authorization):
```js
var options = { issuers: ["https://example.com/"], claims: { role: "admin" } };
keyfetch.jwt.verify(jwt, options).then(function (decoded) {
console.log(decoded);
}); });
``` ```
Verify a JWT with `jsonwebtoken`: Verify a JWT with `jsonwebtoken`:
```js ```js
var keyfetch = require('keyfetch'); var keyfetch = require("keyfetch");
var jwt = require('jsonwebtoken'); var jwt = require("jsonwebtoken");
var auth = "..."; // some JWT var auth = "..."; // some JWT
var token = jwt.decode(auth, { json: true, complete: true }) var token = jwt.decode(auth, { json: true, complete: true });
if (!isTrustedIssuer(token.payload.iss)) { if (!isTrustedIssuer(token.payload.iss)) {
throw new Error("untrusted issuer"); throw new Error("untrusted issuer");
} }
keyfetch.oidcJwk( keyfetch.oidcJwk(token.header.kid, token.payload.iss).then(function (result) {
token.header.kid console.log(result.jwk);
, token.payload.iss console.log(result.thumprint);
).then(function (result) { console.log(result.pem);
console.log(result.jwk);
console.log(result.thumprint);
console.log(result.pem);
jwt.verify(jwt, pem); jwt.jwt.verify(jwt, { jwk: result.jwk });
}); });
``` ```
*Note*: You might implement `isTrustedIssuer` one of these: _Note_: You might implement `isTrustedIssuer` one of these:
```js ```js
function isTrustedIssuer(iss) { function isTrustedIssuer(iss) {
return -1 !== [ 'https://partner.com/', 'https://auth0.com/'].indexOf(iss); return -1 !== ["https://partner.com/", "https://auth0.com/"].indexOf(iss);
} }
``` ```
```js ```js
function isTrustedIssuer(iss) { function isTrustedIssuer(iss) {
return /^https:/.test(iss) && // must be a secure domain return (
/(\.|^)example\.com$/.test(iss); // can be example.com or any subdomain /^https:/.test(iss) && /(\.|^)example\.com$/.test(iss) // must be a secure domain
); // can be example.com or any subdomain
} }
``` ```
@ -106,12 +113,12 @@ Retrieves keys from a URL such as `https://example.com/jwks/` with the format `{
and returns the array of keys (as well as thumbprint and jwk-to-pem). and returns the array of keys (as well as thumbprint and jwk-to-pem).
```js ```js
keyfetch.jwks(jwksUrl) keyfetch.jwks(jwksUrl);
// Promises [ { jwk, thumbprint, pem } ] or fails // Promises [ { jwk, thumbprint, pem } ] or fails
``` ```
```js ```js
keyfetch.jwk(id, jwksUrl) keyfetch.jwk(id, jwksUrl);
// Promises { jwk, thumbprint, pem } or fails // Promises { jwk, thumbprint, pem } or fails
``` ```
@ -121,12 +128,12 @@ If `https://example.com/` is used as `issuerUrl` it will resolve to
`https://example.com/.well-known/jwks.json` and return the keys. `https://example.com/.well-known/jwks.json` and return the keys.
```js ```js
keyfetch.wellKnownJwks(issuerUrl) keyfetch.wellKnownJwks(issuerUrl);
// Promises [ { jwk, thumbprint, pem } ] or fails // Promises [ { jwk, thumbprint, pem } ] or fails
``` ```
```js ```js
keyfetch.wellKnownJwk(id, issuerUrl) keyfetch.wellKnownJwk(id, issuerUrl);
// Promises { jwk, thumbprint, pem } or fails // Promises { jwk, thumbprint, pem } or fails
``` ```
@ -136,37 +143,106 @@ If `https://example.com/` is used as `issuerUrl` then it will first resolve to
`https://example.com/.well-known/openid-configuration` and then follow `jwks_uri` to return the keys. `https://example.com/.well-known/openid-configuration` and then follow `jwks_uri` to return the keys.
```js ```js
keyfetch.oidcJwks(issuerUrl) keyfetch.oidcJwks(issuerUrl);
// Promises [ { jwk, thumbprint, pem } ] or fails // Promises [ { jwk, thumbprint, pem } ] or fails
``` ```
```js ```js
keyfetch.oidcJwk(id, issuerUrl) keyfetch.oidcJwk(id, issuerUrl);
// Promises { jwk, thumbprint, pem } or fails // Promises { jwk, thumbprint, pem } or fails
``` ```
### Verify JWT ### Verify JWT
This can accept a _JWT string_ (compact JWS) or a _decoded JWT object_ (JWS).
This can be used purely for verifying pure authentication tokens, as well as authorization tokens.
```js ```js
keyfetch.verify({ jwt: jwk, strategy: 'oidc' }) keyfetch.jwt.verify(jwt, { strategy: "oidc" }).then(function (verified) {
// Promises a decoded JWT { headers, payload, signature } or fails /*
{ protected: '...' // base64 header
, payload: '...' // base64 payload
, signature: '...' // base64 signature
, header: {...} // decoded header
, claims: {...} // decoded payload
}
*/
});
``` ```
* `strategy` may be `oidc` (default) , `auth0`, or a direct JWKs url. 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).
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' }
}).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)
- `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)
- `jwk` same as above, but a single key rather than a list
### Decode JWT
```jwt
try {
console.log( keyfetch.jwt.decode(jwt) );
} catch(e) {
console.error(e);
}
```
```js
{ protected: '...' // base64 header
, payload: '...' // base64 payload
, signature: '...' // base64 signature
, header: {...} // decoded header
, claims: {...} // decoded payload
```
It's easier just to show the code than to explain the example.
```js
keyfetch.jwt.decode = function (jwt) {
// Unpack JWS from "compact" form
var parts = jwt.split(".");
var obj = {
protected: parts[0],
payload: parts[1],
signature: parts[2]
};
// Decode JWT properties from JWS as unordered objects
obj.header = JSON.parse(Buffer.from(obj.protected, "base64"));
obj.claims = JSON.parse(Buffer.from(obj.payload, "base64"));
return obj;
};
```
### Cache Settings ### Cache Settings
```js ```js
keyfetch.init({ keyfetch.init({
// set all keys at least 1 hour (regardless of jwk.exp) // set all keys at least 1 hour (regardless of jwk.exp)
mincache: 1 * 60 * 60 mincache: 1 * 60 * 60,
// expire each key after 3 days (regardless of jwk.exp) // expire each key after 3 days (regardless of jwk.exp)
, maxcache: 3 * 24 * 60 * 60 maxcache: 3 * 24 * 60 * 60,
// re-fetch a key up to 15 minutes before it expires (only if used) // re-fetch a key up to 15 minutes before it expires (only if used)
, staletime: 15 * 60 staletime: 15 * 60
}) });
``` ```
There is no background task to cleanup expired keys as of yet. There is no background task to cleanup expired keys as of yet.

View File

@ -1,24 +1,156 @@
'use strict'; "use strict";
var keyfetch = require('./keyfetch.js'); var keyfetch = require("./keyfetch.js");
var testIss = "https://example.auth0.com"; var testIss = "https://example.auth0.com";
keyfetch.init({}); keyfetch.init({});
keyfetch.oidcJwks(testIss).then(function (hits) { keyfetch
keyfetch._clear(); .oidcJwks(testIss)
console.log(hits); .then(function (hits) {
return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function () { keyfetch._clear();
return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function (jwk) { //console.log(hits);
console.log(jwk); return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function () {
return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function (/*jwk*/) {
//console.log(jwk);
});
});
})
.then(function () {
console.log("Fetching PASSES");
})
.catch(function (err) {
console.error("NONE SHALL PASS!");
console.error(err);
process.exit(1);
}); });
});
}).catch(function (err) {
console.error(err);
});
/*global Promise*/
var keypairs = require("keypairs.js");
keypairs.generate().then(function (pair) {
return Promise.all([
keypairs
.signJwt({
jwk: pair.private,
iss: "https://example.com/",
sub: "mikey",
exp: "1h"
})
.then(function (jwt) {
return Promise.all([
keyfetch.jwt.verify(jwt, { jwk: pair.public }).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(jwt, {
jwk: pair.public,
issuers: ["https://example.com/"]
}),
keyfetch.jwt.verify(jwt, {
jwk: pair.public,
issuers: ["https://example.com"]
}),
keyfetch.jwt.verify(jwt, {
jwk: pair.public,
issuers: ["example.com"]
}),
keyfetch.jwt.verify(jwt, {
jwk: pair.public,
issuers: ["example.com/"]
}),
keyfetch.jwt.verify(jwt, {
jwk: pair.public,
issuers: ["*"]
}),
keyfetch.jwt
.verify(jwt, {
jwk: pair.public,
issuers: ["http://example.com"]
})
.then(e("bad scheme"))
.catch(throwIfNotExpected),
keyfetch.jwt
.verify(jwt, {
jwk: pair.public,
issuers: ["https://www.example.com"]
})
.then(e("bad prefix"))
.catch(throwIfNotExpected),
keyfetch.jwt
.verify(jwt, {
jwk: pair.public,
issuers: ["https://wexample.com"]
})
.then(e("bad sld"))
.catch(throwIfNotExpected),
keyfetch.jwt
.verify(jwt, {
jwk: pair.public,
issuers: ["https://example.comm"]
})
.then(e("bad tld"))
.catch(throwIfNotExpected),
keyfetch.jwt.verify(jwt, {
jwk: pair.public,
claims: { iss: "https://example.com/" }
}),
keyfetch.jwt
.verify(jwt, {
jwk: pair.public,
claims: { iss: "https://example.com" }
})
.then(e("inexact claim"))
.catch(throwIfNotExpected)
]);
}),
keypairs
.signJwt({
jwk: pair.private,
iss: false,
sub: "mikey",
exp: "1h"
})
.then(function (jwt) {
return Promise.all([
keyfetch.jwt.verify(jwt, { jwk: pair.public }),
keyfetch.jwt.verify(jwt).then(e("should have an issuer")).catch(throwIfNotExpected),
keyfetch.jwt
.verify(jwt, {
jwk: pair.public,
issuers: ["https://example.com/"]
})
.then(e("fail when issuer specified and doesn't exist"))
.catch(throwIfNotExpected)
]);
})
])
.then(function () {
console.log("JWT PASSES");
})
.catch(function (err) {
console.error("NONE SHALL PASS!");
console.error(err);
process.exit(1);
});
});
/* /*
var jwt = '...'; var jwt = '...';
keyfetch.verify({ jwt: jwt }).catch(function (err) { keyfetch.verify({ jwt: jwt }).catch(function (err) {
console.log(err); console.log(err);
}); });
*/ */
function e(msg) {
return new Error("ETEST: " + msg);
}
function throwIfNotExpected(err) {
if ("ETEST" === err.message.slice(0, 5)) {
throw err;
}
}

View File

@ -1,11 +1,11 @@
'use strict'; "use strict";
var keyfetch = module.exports; var keyfetch = module.exports;
var promisify = require('util').promisify; var promisify = require("util").promisify;
var requestAsync = promisify(require('@coolaj86/urequest')); var requestAsync = promisify(require("@coolaj86/urequest"));
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;
var maxcache = 3 * 24 * 60 * 60; var maxcache = 3 * 24 * 60 * 60;
var staletime = 15 * 60; var staletime = 15 * 60;
@ -13,336 +13,415 @@ var keyCache = {};
/*global Promise*/ /*global Promise*/
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) { return d; } if (!i && 0 !== i) {
if (i >= n && i >= x) { return d;
return parseInt(i, 10); }
} else { if (i >= n && i >= x) {
throw new Error("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i); return parseInt(i, 10);
} } else {
throw new Error("opts." + key + " should be at least " + n + " and at most " + x + ", not " + i);
}
} }
keyfetch._clear = function () { keyfetch._clear = function () {
keyCache = {}; keyCache = {};
}; };
keyfetch.init = function (opts) { keyfetch.init = function (opts) {
mincache = checkMinDefaultMax(opts, 'mincache', mincache = checkMinDefaultMax(opts, "mincache", 1 * 60, mincache, 31 * 24 * 60 * 60);
1 * 60, maxcache = checkMinDefaultMax(opts, "maxcache", 1 * 60 * 60, maxcache, 31 * 24 * 60 * 60);
mincache, staletime = checkMinDefaultMax(opts, "staletime", 1 * 60, staletime, 31 * 24 * 60 * 60);
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
);
}; };
keyfetch._oidc = function (iss) { keyfetch._oidc = function (iss) {
return Promise.resolve().then(function () { return Promise.resolve().then(function () {
return requestAsync({ return requestAsync({
url: normalizeIss(iss) + '/.well-known/openid-configuration' url: normalizeIss(iss) + "/.well-known/openid-configuration",
, json: true json: true
}).then(function (resp) { }).then(function (resp) {
var oidcConf = resp.body; var oidcConf = resp.body;
if (!oidcConf.jwks_uri) { if (!oidcConf.jwks_uri) {
throw new Error("Failed to retrieve openid configuration"); throw new Error("Failed to retrieve openid configuration");
} }
return oidcConf; return oidcConf;
});
}); });
});
}; };
keyfetch._wellKnownJwks = function (iss) { keyfetch._wellKnownJwks = function (iss) {
return Promise.resolve().then(function () { return Promise.resolve().then(function () {
return keyfetch._jwks(normalizeIss(iss) + '/.well-known/jwks.json'); return keyfetch._jwks(normalizeIss(iss) + "/.well-known/jwks.json");
}); });
}; };
keyfetch._jwks = function (iss) { keyfetch._jwks = function (iss) {
return requestAsync({ url: iss, json: true }).then(function (resp) { return requestAsync({ url: iss, json: true }).then(function (resp) {
return Promise.all(resp.body.keys.map(function (jwk) { return Promise.all(
// EC keys have an x values, whereas RSA keys do not resp.body.keys.map(function (jwk) {
var Keypairs = jwk.x ? Eckles : Rasha; // EC keys have an x values, whereas RSA keys do not
return Keypairs.thumbprint({ jwk: jwk }).then(function (thumbprint) { var Keypairs = jwk.x ? Eckles : Rasha;
return Keypairs.export({ jwk: jwk }).then(function (pem) { return Keypairs.thumbprint({ jwk: jwk }).then(function (thumbprint) {
var cacheable = { return Keypairs.export({ jwk: jwk }).then(function (pem) {
jwk: jwk var cacheable = {
, thumbprint: thumbprint jwk: jwk,
, pem: pem thumbprint: thumbprint,
}; pem: pem
return cacheable; };
}); return cacheable;
}); });
})); });
}); })
);
});
}; };
keyfetch.jwks = function (jwkUrl) { keyfetch.jwks = function (jwkUrl) {
// TODO DRY up a bit // TODO DRY up a bit
return keyfetch._jwks(jwkUrl).then(function (results) { return keyfetch._jwks(jwkUrl).then(function (results) {
return Promise.all(results.map(function (result) { return Promise.all(
return keyfetch._setCache(result.jwk.iss || jwkUrl, result); results.map(function (result) {
})).then(function () { return keyfetch._setCache(result.jwk.iss || jwkUrl, result);
// cacheable -> hit (keep original externally immutable) })
return JSON.parse(JSON.stringify(results)); ).then(function () {
// cacheable -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
});
}); });
});
}; };
keyfetch.wellKnownJwks = function (iss) { keyfetch.wellKnownJwks = function (iss) {
// TODO DRY up a bit // TODO DRY up a bit
return keyfetch._wellKnownJwks(iss).then(function (results) { return keyfetch._wellKnownJwks(iss).then(function (results) {
return Promise.all(results.map(function (result) { return Promise.all(
return keyfetch._setCache(result.jwk.iss || iss, result); results.map(function (result) {
})).then(function () { return keyfetch._setCache(result.jwk.iss || iss, result);
// result -> hit (keep original externally immutable) })
return JSON.parse(JSON.stringify(results)); ).then(function () {
// result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
});
}); });
});
}; };
keyfetch.oidcJwks = function (iss) { keyfetch.oidcJwks = function (iss) {
return keyfetch._oidc(iss).then(function (oidcConf) { return keyfetch._oidc(iss).then(function (oidcConf) {
// TODO DRY up a bit // TODO DRY up a bit
return keyfetch._jwks(oidcConf.jwks_uri).then(function (results) { return keyfetch._jwks(oidcConf.jwks_uri).then(function (results) {
return Promise.all(results.map(function (result) { return Promise.all(
return keyfetch._setCache(result.jwk.iss || iss, result); results.map(function (result) {
})).then(function () { return keyfetch._setCache(result.jwk.iss || iss, result);
// result -> hit (keep original externally immutable) })
return JSON.parse(JSON.stringify(results)); ).then(function () {
}); // result -> hit (keep original externally immutable)
return JSON.parse(JSON.stringify(results));
});
});
}); });
});
}; };
function checkId(id) { function checkId(id) {
return function (results) { return function (results) {
var result = results.filter(function (result) { var result = results.filter(function (result) {
// we already checked iss above // we already checked iss above
return result.jwk.kid === id || result.thumbprint === id; return result.jwk.kid === id || result.thumbprint === id;
})[0]; })[0];
if (!result) { if (!result) {
throw new Error("No JWK found by kid or thumbprint '" + id + "'"); throw new Error("No JWK found by kid or thumbprint '" + id + "'");
} }
return result; return result;
}; };
} }
keyfetch.oidcJwk = function (id, iss) { keyfetch.oidcJwk = function (id, iss) {
return keyfetch._checkCache(id, iss).then(function (hit) { return keyfetch._checkCache(id, iss).then(function (hit) {
if (hit) { return hit; } if (hit) {
return keyfetch.oidcJwks(iss).then(checkId(id)); return hit;
}); }
return keyfetch.oidcJwks(iss).then(checkId(id));
});
}; };
keyfetch.wellKnownJwk = function (id, iss) { keyfetch.wellKnownJwk = function (id, iss) {
return keyfetch._checkCache(id, iss).then(function (hit) { return keyfetch._checkCache(id, iss).then(function (hit) {
if (hit) { return hit; } if (hit) {
return keyfetch.wellKnownJwks(iss).then(checkId(id)); return hit;
}); }
return keyfetch.wellKnownJwks(iss).then(checkId(id));
});
}; };
keyfetch.jwk = function (id, jwksUrl) { keyfetch.jwk = function (id, jwksUrl) {
return keyfetch._checkCache(id, jwksUrl).then(function (hit) { return keyfetch._checkCache(id, jwksUrl).then(function (hit) {
if (hit) { return hit; } if (hit) {
return keyfetch.jwks(jwksUrl).then(checkId(id)); return hit;
}); }
return keyfetch.jwks(jwksUrl).then(checkId(id));
});
}; };
keyfetch._checkCache = function (id, iss) { keyfetch._checkCache = function (id, iss) {
return Promise.resolve().then(function () { return Promise.resolve().then(function () {
// We cache by thumbprint and (kid + '@' + iss), // We cache by thumbprint and (kid + '@' + iss),
// so it's safe to check without appending the issuer // so it's safe to check without appending the issuer
var hit = keyCache[id]; var hit = keyCache[id];
if (!hit) { if (!hit) {
hit = keyCache[id + '@' + normalizeIss(iss)]; hit = keyCache[id + "@" + normalizeIss(iss)];
} }
if (!hit) { if (!hit) {
return null; 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
var expiresAt = parseInt(cacheable.jwk.exp, 10) || 0; var expiresAt = parseInt(cacheable.jwk.exp, 10) || 0;
var now = Date.now() / 1000; var now = Date.now() / 1000;
var left = expiresAt - now; var left = expiresAt - now;
// TODO maybe log out when any of these non-ideal cases happen? // TODO maybe log out when any of these non-ideal cases happen?
if (!left) { if (!left) {
expiresAt = now + maxcache; expiresAt = now + maxcache;
} else if (left < mincache) { } else if (left < mincache) {
expiresAt = now + mincache; expiresAt = now + mincache;
} else if (left > maxcache) { } else if (left > maxcache) {
expiresAt = now + maxcache; expiresAt = now + maxcache;
} }
// cacheable = { jwk, thumprint, pem } // cacheable = { jwk, thumprint, pem }
cacheable.createdAt = now; cacheable.createdAt = now;
cacheable.expiresAt = expiresAt; cacheable.expiresAt = expiresAt;
keyCache[cacheable.thumbprint] = cacheable; keyCache[cacheable.thumbprint] = cacheable;
keyCache[cacheable.jwk.kid + '@' + normalizeIss(iss)] = cacheable; keyCache[cacheable.jwk.kid + "@" + normalizeIss(iss)] = cacheable;
}; };
function normalizeIss(iss) { function normalizeIss(iss) {
// 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 new Error("'" + iss + "' is NOT secure. Set env 'KEYFETCH_ALLOW_INSECURE_HTTP=true' to allow for testing."); 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.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;
};
keyfetch.jwt.verify = 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 + "'");
}
// TODO verify claims also?
if (
!Object.keys(claims).every(function (key) {
if (claims[key] === decoded.claims[key]) {
return true;
}
})
) {
throw new Error("token did not match on one or more authorization claims: '" + Object.keys(claims) + "'");
}
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);
}
});
};
keyfetch.jws = {};
keyfetch.jws.verify = function (jws, pub) {
var alg = "SHA" + jws.header.alg.replace(/[^\d]+/i, "");
var sig = ecdsaJoseSigToAsn1Sig(jws.header, jws.signature);
return require("crypto")
.createVerify(alg)
.update(jws.protected + "." + jws.payload)
.verify(pub.pem, sig, "base64");
};
// old, gotta make sure nothing else uses this
keyfetch._decode = function (jwt) { keyfetch._decode = function (jwt) {
var parts = jwt.split('.'); var obj = keyfetch.jwt.decode(jwt);
return { return { header: obj.header, payload: obj.claims, signature: obj.signature };
header: JSON.parse(Buffer.from(parts[0], 'base64'))
, payload: JSON.parse(Buffer.from(parts[1], 'base64'))
, signature: parts[2] //Buffer.from(parts[2], 'base64')
};
}; };
keyfetch.verify = function (opts) { keyfetch.verify = function (opts) {
var jwt = opts.jwt; var jwt = opts.jwt;
return Promise.resolve().then(function () { return keyfetch.jwt.verify(jwt, opts).then(function (obj) {
var decoded; return { header: obj.header, payload: obj.claims, signature: obj.signature };
var exp; });
var nbf;
var valid;
try {
decoded = keyfetch._decode(jwt);
exp = decoded.payload.exp;
nbf = decoded.payload.nbf;
} catch (e) {
throw new Error("could not parse opts.jwt: '" + jwt + "'");
}
if (exp) {
valid = (parseInt(exp, 10) - (Date.now()/1000) > 0);
if (!valid) {
throw new Error("token's 'exp' has passed or could not parsed: '" + exp + "'");
}
}
if (nbf) {
valid = (parseInt(nbf, 10) - (Date.now()/1000) <= 0);
if (!valid) {
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]);
}
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);
}
var kid = decoded.header.kid;
var iss;
var fetcher;
var fetchOne;
if (!opts.strategy || 'oidc' === opts.strategy) {
iss = decoded.payload.iss;
fetcher = keyfetch.oidcJwks;
fetchOne = keyfetch.oidcJwk;
} else if ('auth0' === opts.strategy || 'well-known' === opts.strategy) {
iss = decoded.payload.iss;
fetcher = keyfetch.wellKnownJwks;
fetchOne = keyfetch.wellKnownJwk;
} else {
iss = opts.strategy;
fetcher = keyfetch.jwks;
fetchOne = keyfetch.jwk;
}
var payload = jwt.split('.')[1]; // as string, as it was signed
if (kid) {
return fetchOne(kid, iss).then(verifyOne); //.catch(fetchAny);
} else {
return fetcher(iss).then(verifyAny);
}
function verify(hit, payload) {
var alg = 'SHA' + decoded.header.alg.replace(/[^\d]+/i, '');
var sig = ecdsaAsn1SigToJwtSig(decoded.header, decoded.signature);
return require('crypto')
.createVerify(alg)
.update(jwt.split('.')[0] + '.' + payload)
.verify(hit.pem, sig, 'base64')
;
}
function verifyOne(hit) {
if (true === verify(hit, payload)) {
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 === verify(hit, payload)) { return true; }
throw new Error('token signature verification was unsuccessful');
} else {
if (true === verify(hit, payload)) { 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 ecdsaAsn1SigToJwtSig(header, b64sig) { function ecdsaJoseSigToAsn1Sig(header, b64sig) {
// ECDSA JWT signatures differ from "normal" ECDSA signatures // ECDSA JWT signatures differ from "normal" ECDSA signatures
// https://tools.ietf.org/html/rfc7518#section-3.4 // https://tools.ietf.org/html/rfc7518#section-3.4
if (!/^ES/i.test(header.alg)) { return b64sig; } if (!/^ES/i.test(header.alg)) {
return b64sig;
}
var bufsig = Buffer.from(b64sig, 'base64'); var bufsig = Buffer.from(b64sig, "base64");
var hlen = bufsig.byteLength / 2; // should be even var hlen = bufsig.byteLength / 2; // should be even
var r = bufsig.slice(0, hlen); var r = bufsig.slice(0, hlen);
var s = bufsig.slice(hlen); var s = bufsig.slice(hlen);
// unpad positive ints less than 32 bytes wide // unpad positive ints less than 32 bytes wide
while (!r[0]) { r = r.slice(1); } while (!r[0]) {
while (!s[0]) { s = s.slice(1); } r = r.slice(1);
// pad (or re-pad) ambiguously non-negative BigInts to 33 bytes wide }
if (0x80 & r[0]) { r = Buffer.concat([Buffer.from([0]), r]); } while (!s[0]) {
if (0x80 & s[0]) { s = Buffer.concat([Buffer.from([0]), s]); } s = s.slice(1);
}
// pad (or re-pad) ambiguously non-negative BigInts to 33 bytes wide
if (0x80 & r[0]) {
r = Buffer.concat([Buffer.from([0]), r]);
}
if (0x80 & s[0]) {
s = Buffer.concat([Buffer.from([0]), s]);
}
var len = 2 + r.byteLength + 2 + s.byteLength; var len = 2 + r.byteLength + 2 + s.byteLength;
var head = [0x30]; var head = [0x30];
// hard code 0x80 + 1 because it won't be longer than // hard code 0x80 + 1 because it won't be longer than
// two SHA512 plus two pad bytes (130 bytes <= 256) // two SHA512 plus two pad bytes (130 bytes <= 256)
if (len >= 0x80) { head.push(0x81); } if (len >= 0x80) {
head.push(len); head.push(0x81);
}
head.push(len);
var buf = Buffer.concat([ var buf = Buffer.concat([
Buffer.from(head) Buffer.from(head),
, Buffer.from([0x02, r.byteLength]), r Buffer.from([0x02, r.byteLength]),
, Buffer.from([0x02, s.byteLength]), s r,
]); Buffer.from([0x02, s.byteLength]),
s
]);
return buf.toString('base64') return buf.toString("base64").replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, "");
.replace(/-/g, '+') }
.replace(/_/g, '/')
.replace(/=/g, '') function isTrustedIssuer(issuer) {
; return function (trusted) {
if ("*" === trusted) {
return true;
}
// TODO account for '*.example.com'
trusted = /^http(s?):\/\//.test(trusted) ? trusted : "https://" + trusted;
return issuer.replace(/\/$/, "") === trusted.replace(/\/$/, "") && trusted;
};
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "keyfetch", "name": "keyfetch",
"version": "1.1.10", "version": "1.2.1",
"description": "Lightweight support for fetching JWKs.", "description": "Lightweight support for fetching JWKs.",
"homepage": "https://git.coolaj86.com/coolaj86/keyfetch.js", "homepage": "https://git.coolaj86.com/coolaj86/keyfetch.js",
"main": "keyfetch.js", "main": "keyfetch.js",