Compare commits

...

13 Commits

8 changed files with 727 additions and 311 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

7
.prettierrc Normal file
View File

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

41
LICENSE Normal file
View File

@ -0,0 +1,41 @@
Copyright 2019 AJ ONeal
This is open source software; you can redistribute it and/or modify it under the
terms of either:
a) the "MIT License"
b) the "Apache-2.0 License"
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Apache-2.0 License Summary
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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,20 +1,156 @@
'use strict'; "use strict";
var keyfetch = require('./keyfetch.js'); var keyfetch = require("./keyfetch.js");
var testIss = "https://example.auth0.com";
keyfetch.init({}); keyfetch.init({});
keyfetch.oidcJwks("https://bigsquid.auth0.com").then(function (jwks) { keyfetch
console.log(jwks); .oidcJwks(testIss)
return keyfetch.oidcJwk(jwks[0].thumbprint, "https://bigsquid.auth0.com").then(function (jwk) { .then(function (hits) {
console.log(jwk); keyfetch._clear();
}); //console.log(hits);
}).catch(function (err) { return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function () {
console.error(err); 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);
});
/*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,261 +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 () {
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.some(function (result) { var result = results.filter(function (result) {
// we already checked iss above // we already checked iss above
console.log(result); 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 + "'");
}
}
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.");
}); });
});
}; };
function ecdsaJoseSigToAsn1Sig(header, b64sig) {
// ECDSA JWT signatures differ from "normal" ECDSA signatures
// https://tools.ietf.org/html/rfc7518#section-3.4
if (!/^ES/i.test(header.alg)) {
return b64sig;
}
var bufsig = Buffer.from(b64sig, "base64");
var hlen = bufsig.byteLength / 2; // should be even
var r = bufsig.slice(0, hlen);
var s = bufsig.slice(hlen);
// unpad positive ints less than 32 bytes wide
while (!r[0]) {
r = r.slice(1);
}
while (!s[0]) {
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 head = [0x30];
// hard code 0x80 + 1 because it won't be longer than
// two SHA512 plus two pad bytes (130 bytes <= 256)
if (len >= 0x80) {
head.push(0x81);
}
head.push(len);
var buf = Buffer.concat([
Buffer.from(head),
Buffer.from([0x02, r.byteLength]),
r,
Buffer.from([0x02, s.byteLength]),
s
]);
return buf.toString("base64").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;
};
}

20
package-lock.json generated
View File

@ -1,23 +1,23 @@
{ {
"name": "keyfetch", "name": "keyfetch",
"version": "1.1.0", "version": "1.1.8",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
"@coolaj86/urequest": { "@coolaj86/urequest": {
"version": "1.3.6", "version": "1.3.7",
"resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.6.tgz", "resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.7.tgz",
"integrity": "sha512-9rBXLFSb5D19opGeXdD/WuiFJsA4Pk2r8VUGEAeUZUxB1a2zB47K85BKAx3Gy9i4nZwg22ejlJA+q9DVrpQlbA==" "integrity": "sha512-PPrVYra9aWvZjSCKl/x1pJ9ZpXda1652oJrPBYy5rQumJJMkmTBN3ux+sK2xAUwVvv2wnewDlaQaHLxLwSHnIA=="
}, },
"eckles": { "eckles": {
"version": "1.4.0", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.0.tgz", "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz",
"integrity": "sha512-Bm5dpwhsBuoCHvKCY3gAvP8XFyXH7im8uAu3szykpVNbFBdC+lOuV8vLC8fvTYRZBfFqB+k/P6ud/ZPVO2V2tA==" "integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA=="
}, },
"rasha": { "rasha": {
"version": "1.2.1", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.1.tgz", "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz",
"integrity": "sha512-cs4Hu/rVF3/Qucq+V7lxSz449VfHNMVXJaeajAHno9H5FC1PWlmS4NM6IAX5jPKFF0IC2rOdHdf7iNxQuIWZag==" "integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q=="
} }
} }
} }

View File

@ -1,18 +1,23 @@
{ "author": { {
"name": "AJ ONeal", "name": "keyfetch",
"email": "solderjs@gmail.com" "version": "1.2.1",
},
"bundleDependencies": false,
"dependencies": {
"@coolaj86/urequest": "^1.3.6",
"eckles": "^1.4.0",
"rasha": "^1.2.1"
},
"deprecated": false,
"description": "Lightweight support for fetching JWKs.", "description": "Lightweight support for fetching JWKs.",
"files": [ "homepage": "https://git.coolaj86.com/coolaj86/keyfetch.js",
"keyfetch-test.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": [ "keywords": [
"jwks", "jwks",
"jwk", "jwk",
@ -25,11 +30,7 @@
"OIDC", "OIDC",
"well-known" "well-known"
], ],
"license": "MPL-2.0", "author": "AJ ONeal <solderjs@gmail.com> (https://coolaj86.com/)",
"main": "keyfetch.js", "license": "MPL-2.0"
"name": "keyfetch",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"version": "1.1.2"
} }