From 6c071af9cd140cd799c2af492d0f048d09e1c0ae Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 25 Feb 2019 15:54:08 -0700 Subject: [PATCH] initial commit --- README.md | 173 ++++++++++++++++++++++++++ keyfetch-test.js | 20 +++ keyfetch.js | 301 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 23 ++++ package.json | 35 ++++++ 5 files changed, 552 insertions(+) create mode 100644 README.md create mode 100644 keyfetch-test.js create mode 100644 keyfetch.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b3d5ea --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# keyfetch + +Lightweight support for fetching JWKs. + +Fetches JSON native JWKs and exposes them as PEMs that can be consumed by the `jsonwebtoken` package +(and node's native RSA and ECDSA crypto APIs). + +## Features + +Works great for + +* [x] `jsonwebtoken` (Auth0) +* [x] OIDC (OpenID Connect) +* [x] .well-known/jwks.json (Auth0) +* [x] Other JWKs URLs + +Crypto Support + +* [x] JWT verification +* [x] RSA (all variants) +* [x] EC / ECDSA (NIST variants P-256, P-384) +* [ ] esoteric variants (excluded to keep the code featherweight and secure) + +# Install + +```bash +npm install --save keyfetch +``` + +# Usage + +Retrieve a key list of keys: + +```js +var keyfetch = require('keyfetch'); + +keyfetch.oidcJwks("https://example.com/").then(function (results) { + results.forEach(function (result) { + console.log(result.jwk); + console.log(result.thumprint); + console.log(result.pem); + }); +}); +``` + +Quick JWT verification: + +```js +var keyfetch = require('keyfetch'); +var jwt = '...'; + +keyfetch.verify({ jwt: jwt }).then(function (decoded) { + console.log(decoded); +}); +``` + +Verify a JWT with `jsonwebtoken`: + +```js +var keyfetch = require('keyfetch'); +var jwt = require('jsonwebtoken'); +var auth = "..."; // some JWT +var token = jwt.decode(auth, { json: true, complete: true }) + +if (!isTrustedIssuer(token.payload.iss)) { + throw new Error("untrusted issuer"); +} + +keyfetch.oidcJwk( + token.header.kid +, token.payload.iss +).then(function (result) { + console.log(result.jwk); + console.log(result.thumprint); + console.log(result.pem); + + jwt.verify(jwt, pem); +}); +``` + +*Note*: You might implement `isTrustedIssuer` one of these: + +```js +function isTrustedIssuer(iss) { + return -1 !== [ 'https://partner.com/', 'https://auth0.com/'].indexOf(iss); +} +``` + +```js +function isTrustedIssuer(iss) { + return /^https:/.test(iss) && // must be a secure domain + /(\.|^)example\.com$/.test(iss); // can be example.com or any subdomain +} +``` + +# API + +All API calls will return the RFC standard JWK SHA256 thumbprint as well as a PEM version of the key. + +Note: When specifying `id`, it may be either `kid` (as in `token.header.kid`) +or `thumbprint` (as in `result.thumbprint`). + +### JWKs URLs + +Retrieves keys from a URL such as `https://example.com/jwks/` with the format `{ keys: [ { kid, kty, exp, ... } ] }` +and returns the array of keys (as well as thumbprint and jwk-to-pem). + +```js +keyfetch.jwks(jwksUrl) +// Promises [ { jwk, thumbprint, pem } ] or fails +``` + +```js +keyfetch.jwk(id, jwksUrl) +// Promises { jwk, thumbprint, pem } or fails +``` + +### Auth0 + +If `https://example.com/` is used as `issuerUrl` it will resolve to +`https://example.com/.well-known/jwks.json` and return the keys. + +```js +keyfetch.wellKnownJwks(issuerUrl) +// Promises [ { jwk, thumbprint, pem } ] or fails +``` + +```js +keyfetch.wellKnownJwk(id, issuerUrl) +// Promises { jwk, thumbprint, pem } or fails +``` + +### OIDC + +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. + +```js +keyfetch.oidcJwks(issuerUrl) +// Promises [ { jwk, thumbprint, pem } ] or fails +``` + +```js +keyfetch.oidcJwk(id, issuerUrl) +// Promises { jwk, thumbprint, pem } or fails +``` + +### Verify JWT + +```js +keyfetch.verify({ jwt: jwk, strategy: 'oidc' }) +// Promises a decoded JWT { headers, payload, signature } or fails +``` + +* `strategy` may be `oidc` (default) , `auth0`, or a direct JWKs url. + +### Cache Settings + +```js +keyfetch.init({ + // set all keys at least 1 hour (regardless of jwk.exp) + mincache: 1 * 60 * 60 + + // expire each key after 3 days (regardless of jwk.exp) +, maxcache: 3 * 24 * 60 * 60 + + // re-fetch a key up to 15 minutes before it expires (only if used) +, staletime: 15 * 60 +}) +``` + +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. diff --git a/keyfetch-test.js b/keyfetch-test.js new file mode 100644 index 0000000..e60cc61 --- /dev/null +++ b/keyfetch-test.js @@ -0,0 +1,20 @@ +'use strict'; + +var keyfetch = require('./keyfetch.js'); + +keyfetch.init({}); +keyfetch.oidcJwks("https://bigsquid.auth0.com").then(function (jwks) { + console.log(jwks); + return keyfetch.oidcJwk(jwks[0].thumbprint, "https://bigsquid.auth0.com").then(function (jwk) { + console.log(jwk); + }); +}).catch(function (err) { + console.error(err); +}); + +/* +var jwt = '...'; +keyfetch.verify({ jwt: jwt }).catch(function (err) { + console.log(err); +}); +*/ diff --git a/keyfetch.js b/keyfetch.js new file mode 100644 index 0000000..0e5b6ae --- /dev/null +++ b/keyfetch.js @@ -0,0 +1,301 @@ +'use strict'; + +var keyfetch = module.exports; + +var promisify = require('util').promisify; +var requestAsync = promisify(require('@coolaj86/urequest')); +var Rasha = require('rasha'); +var Eckles = require('eckles'); +var mincache = 1 * 60 * 60; +var maxcache = 3 * 24 * 60 * 60; +var staletime = 15 * 60; +var keyCache = {}; + +/*global Promise*/ +function checkMinDefaultMax(opts, key, n, d, x) { + var i = opts[key]; + if (!i && 0 !== i) { return d; } + 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); + } +} + +keyfetch.init = function (opts) { + mincache = checkMinDefaultMax(opts, 'mincache', + 1 * 60, + mincache, + 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) { + 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._wellKnownJwks = function (iss) { + return Promise.resolve().then(function () { + 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 = 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)); + }); + }); +}; +keyfetch.wellKnownJwks = 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)); + }); + }); +}; +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.oidcJwk = function (id, iss) { + // TODO [2] DRY this up a bit + return keyfetch._checkCache(id, iss).then(function (hit) { + if (hit) { + return Promise.resolve(hit); + } + + return keyfetch.oidcJwks(iss).then(function (results) { + var result = results.some(function (result) { + // we already checked iss above + return result.jwk.kid === id || result.thumbprint === id; + })[0]; + + if (!result) { + throw new Error("No JWK found by kid or thumbprint '" + id + "'"); + } + return result; + }); + }); +}; +keyfetch.wellKnownJwk = function (id, iss) { + // TODO [2] DRY this up a bit + return keyfetch._checkCache(id, iss).then(function (hit) { + if (hit) { + return Promise.resolve(hit); + } + + return keyfetch.wellKnownJwks(iss).then(function (results) { + var result = results.some(function (result) { + // we already checked iss above + return result.jwk.kid === id || result.thumbprint === id; + })[0]; + + if (!result) { + throw new Error("No JWK found by kid or thumbprint '" + id + "'"); + } + return result; + }); + }); +}; +keyfetch.jwk = function (id, jwksUrl) { + // TODO [2] DRY this up a bit + return keyfetch._checkCache(id, jwksUrl).then(function (hit) { + if (hit) { + return Promise.resolve(hit); + } + + return keyfetch.jwks(jwksUrl).then(function (results) { + var result = results.some(function (result) { + // we already checked iss above + return result.jwk.kid === id || result.thumbprint === id; + })[0]; + + if (!result) { + throw new Error("No JWK found by kid or thumbprint '" + id + "'"); + } + return result; + }); + }); +}; +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)); + } + return null; + }); +}; +keyfetch._setCache = function (iss, cacheable) { + // force into a number + var expiresAt = parseInt(cacheable.jwk.exp, 10) || 0; + var now = Date.now() / 1000; + var left = expiresAt - now; + + // TODO maybe log out when any of these non-ideal cases happen? + if (!left) { + expiresAt = now + maxcache; + } else if (left < mincache) { + expiresAt = now + mincache; + } else if (left > maxcache) { + expiresAt = now + maxcache; + } + + // cacheable = { jwk, thumprint, pem } + cacheable.createdAt = now; + cacheable.expiresAt = expiresAt; + keyCache[cacheable.thumbprint] = cacheable; + keyCache[cacheable.jwk.kid + '@' + normalizeIss(iss)] = cacheable; +}; + +function normalizeIss(iss) { + // 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."); + } + return iss.replace(/\/$/, ''); +} +keyfetch._decode = function (jwt) { + var parts = jwt.split('.'); + return { + 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) { + var jwt = opts.jwt; + return Promise.resolve().then(function () { + var decoded; + 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 + "'"); + } + } + var kid = decoded.header.kid; + var iss; + var fetcher; + if (!opts.strategy || 'oidc' === opts.strategy) { + iss = decoded.payload.iss; + fetcher = keyfetch.oidcJwks; + } else if ('auth0' === opts.strategy || 'well-known' === opts.strategy) { + iss = decoded.payload.iss; + fetcher = keyfetch.wellKnownJwks; + } else { + iss = opts.strategy; + fetcher = keyfetch.jwks; + } + function verify(jwk, payload) { + var alg = 'RSA-SHA' + decoded.header.alg.replace(/[^\d]+/i, ''); + return require('crypto') + .createVerify(alg) + .update(jwt.split('.')[0] + '.' + payload) + .verify(jwk.pem, decoded.signature, 'base64'); + } + return fetcher(iss).then(function (jwks) { + var payload = jwt.split('.')[1]; // as string, as it was signed + if (jwks.some(function (jwk) { + if (kid) { + if (kid !== jwk.kid && kid !== jwk.thumbprint) { return; } + if (verify(jwk, payload)) { return true; } + throw new Error('token signature verification was unsuccessful'); + } else { + if (verify(jwk, 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."); + }); + }); +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..927b11c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "keyfetch", + "version": "1.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@coolaj86/urequest": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.6.tgz", + "integrity": "sha512-9rBXLFSb5D19opGeXdD/WuiFJsA4Pk2r8VUGEAeUZUxB1a2zB47K85BKAx3Gy9i4nZwg22ejlJA+q9DVrpQlbA==" + }, + "eckles": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.0.tgz", + "integrity": "sha512-Bm5dpwhsBuoCHvKCY3gAvP8XFyXH7im8uAu3szykpVNbFBdC+lOuV8vLC8fvTYRZBfFqB+k/P6ud/ZPVO2V2tA==" + }, + "rasha": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.1.tgz", + "integrity": "sha512-cs4Hu/rVF3/Qucq+V7lxSz449VfHNMVXJaeajAHno9H5FC1PWlmS4NM6IAX5jPKFF0IC2rOdHdf7iNxQuIWZag==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d87abfb --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ "author": { + "name": "AJ ONeal", + "email": "solderjs@gmail.com" + }, + "bundleDependencies": false, + "dependencies": { + "@coolaj86/urequest": "^1.3.6", + "eckles": "^1.4.0", + "rasha": "^1.2.1" + }, + "deprecated": false, + "description": "Lightweight support for fetching JWKs.", + "files": [ + "keyfetch-test.js" + ], + "keywords": [ + "jwks", + "jwk", + "jwt", + "auth0", + "pem", + "RSA", + "EC", + "ECDSA", + "OIDC", + "well-known" + ], + "license": "MPL-2.0", + "main": "keyfetch.js", + "name": "keyfetch", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "version": "1.1.1" +}