v1.2.0: add code to check issuers and claims

This commit is contained in:
AJ ONeal 2019-03-15 13:45:27 -06:00
parent 9b77939455
commit 5060c505b6
4 changed files with 259 additions and 85 deletions

View File

@ -43,13 +43,22 @@ keyfetch.oidcJwks("https://example.com/").then(function (results) {
}); });
``` ```
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);
});
```
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); console.log(decoded);
}); });
``` ```
@ -74,7 +83,7 @@ keyfetch.oidcJwk(
console.log(result.thumprint); console.log(result.thumprint);
console.log(result.pem); console.log(result.pem);
jwt.verify(jwt, pem); jwt.jwt.verify(jwt, { jwk: result.jwk });
}); });
``` ```
@ -147,12 +156,81 @@ keyfetch.oidcJwk(id, issuerUrl)
### 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
}
*/
});
```
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. * `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

View File

@ -6,19 +6,71 @@ var testIss = "https://example.auth0.com";
keyfetch.init({}); keyfetch.init({});
keyfetch.oidcJwks(testIss).then(function (hits) { keyfetch.oidcJwks(testIss).then(function (hits) {
keyfetch._clear(); keyfetch._clear();
console.log(hits); //console.log(hits);
return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function () { return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function () {
return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function (jwk) { return keyfetch.oidcJwk(hits[0].thumbprint, testIss).then(function (/*jwk*/) {
console.log(jwk); //console.log(jwk);
}); });
}); });
}).then(function () {
console.log("Fetching PASSES");
}).catch(function (err) { }).catch(function (err) {
console.error("NONE SHALL PASS!");
console.error(err); console.error(err);
process.exit(1);
}); });
/*global Promise*/
var keypairs = require('keypairs.js');
keypairs.generate().then(function (pair) {
return 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: ['*'] })
, 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)
]).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

@ -202,45 +202,116 @@ function normalizeIss(iss) {
} }
return iss.replace(/\/$/, ''); return iss.replace(/\/$/, '');
} }
keyfetch._decode = function (jwt) {
keyfetch.jwt = {};
keyfetch.jwt.decode = function (jwt) {
var parts = jwt.split('.'); var parts = jwt.split('.');
return { // JWS
header: JSON.parse(Buffer.from(parts[0], 'base64')) var obj = {
, payload: JSON.parse(Buffer.from(parts[1], 'base64')) protected: parts[0]
, signature: parts[2] //Buffer.from(parts[2], 'base64') , 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.verify = function (opts) { keyfetch.jwt.verify = function (jwt, opts) {
var jwt = opts.jwt; if (!opts) { opts = {}; }
return Promise.resolve().then(function () { return Promise.resolve().then(function () {
var decoded; var decoded;
var exp; var exp;
var nbf; var nbf;
var valid; var active;
try { var issuers = opts.issuers || ['*'];
decoded = keyfetch._decode(jwt); var claims = opts.claims || {};
exp = decoded.payload.exp; if (!jwt || 'string' === typeof jwt) {
nbf = decoded.payload.nbf; try { decoded = keyfetch.jwt.decode(jwt); }
} catch (e) { catch (e) { throw new Error("could not parse jwt: '" + jwt + "'"); }
throw new Error("could not parse opts.jwt: '" + jwt + "'"); } else {
decoded = jwt;
} }
if (exp) { exp = decoded.claims.exp;
valid = (parseInt(exp, 10) - (Date.now()/1000) > 0); nbf = decoded.claims.nbf;
if (!valid) {
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 + "'"); throw new Error("token's 'exp' has passed or could not parsed: '" + exp + "'");
} }
} }
if (nbf) { if (nbf) {
valid = (parseInt(nbf, 10) - (Date.now()/1000) <= 0); active = (parseInt(nbf, 10) - (Date.now()/1000) <= 0);
if (!valid) { if (!active) {
throw new Error("token's 'nbf' has not been reached or could not parsed: '" + nbf + "'"); throw new Error("token's 'nbf' has not been reached or could not parsed: '" + nbf + "'");
} }
} }
if (opts.jwks || opts.jwk) { if (opts.jwks || opts.jwk) {
return overrideLookup(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) { function overrideLookup(jwks) {
return Promise.all(jwks.map(function (jwk) { return Promise.all(jwks.map(function (jwk) {
var Keypairs = jwk.x ? Eckles : Rasha; var Keypairs = jwk.x ? Eckles : Rasha;
@ -251,63 +322,28 @@ keyfetch.verify = function (opts) {
}); });
})).then(verifyAny); })).then(verifyAny);
} }
});
};
keyfetch.jws = {};
keyfetch.jws.verify = function (jws, pub) {
var alg = 'SHA' + jws.header.alg.replace(/[^\d]+/i, '');
var sig = ecdsaAsn1SigToJwtSig(jws.header, jws.signature);
return require('crypto')
.createVerify(alg)
.update(jws.protected + '.' + jws.payload)
.verify(pub.pem, sig, 'base64')
;
};
var kid = decoded.header.kid; // old, gotta make sure nothing else uses this
var iss; keyfetch._decode = function (jwt) {
var fetcher; var obj = keyfetch.jwt.decode(jwt);
var fetchOne; return { header: obj.header, payload: obj.claims, signature: obj.signature };
if (!opts.strategy || 'oidc' === opts.strategy) { };
iss = decoded.payload.iss; keyfetch.verify = function (opts) {
fetcher = keyfetch.oidcJwks; var jwt = opts.jwt;
fetchOne = keyfetch.oidcJwk; return keyfetch.jwt.verify(jwt, opts).then(function (obj) {
} else if ('auth0' === opts.strategy || 'well-known' === opts.strategy) { return { header: obj.header, payload: obj.claims, signature: obj.signature };
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.");
}
}); });
}; };
@ -346,3 +382,11 @@ function ecdsaAsn1SigToJwtSig(header, b64sig) {
.replace(/=/g, '') .replace(/=/g, '')
; ;
} }
function isTrustedIssuer(issuer) {
return function (trusted) {
if ('*' === trusted) { return true; }
// TODO normalize and account for '*'
return issuer.replace(/\/$/, '') === trusted.replace(/\/$/, '') && trusted;
};
}

View File

@ -1,6 +1,6 @@
{ {
"name": "keyfetch", "name": "keyfetch",
"version": "1.1.10", "version": "1.2.0",
"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",