v1.2.0: add code to check issuers and claims
This commit is contained in:
parent
9b77939455
commit
5060c505b6
88
README.md
88
README.md
|
@ -43,13 +43,22 @@ keyfetch.oidcJwks("https://example.com/").then(function (results) {
|
|||
});
|
||||
```
|
||||
|
||||
Quick JWT verification:
|
||||
Quick JWT verification (for authentication):
|
||||
|
||||
```js
|
||||
var keyfetch = require('keyfetch');
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
@ -74,7 +83,7 @@ keyfetch.oidcJwk(
|
|||
console.log(result.thumprint);
|
||||
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
|
||||
|
||||
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
|
||||
keyfetch.verify({ jwt: jwk, strategy: 'oidc' })
|
||||
// Promises a decoded JWT { headers, payload, signature } or fails
|
||||
keyfetch.jwt.verify(jwt, { strategy: 'oidc' }).then(function (verified) {
|
||||
/*
|
||||
{ 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.
|
||||
* `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
|
||||
|
||||
|
|
|
@ -6,19 +6,71 @@ var testIss = "https://example.auth0.com";
|
|||
keyfetch.init({});
|
||||
keyfetch.oidcJwks(testIss).then(function (hits) {
|
||||
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 (jwk) {
|
||||
console.log(jwk);
|
||||
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 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 = '...';
|
||||
keyfetch.verify({ jwt: jwt }).catch(function (err) {
|
||||
console.log(err);
|
||||
});
|
||||
*/
|
||||
|
||||
function e(msg) {
|
||||
return new Error("ETEST: " + msg);
|
||||
}
|
||||
function throwIfNotExpected(err) {
|
||||
if ("ETEST" === err.message.slice(0, 5)) { throw err; }
|
||||
}
|
||||
|
|
196
keyfetch.js
196
keyfetch.js
|
@ -202,45 +202,116 @@ function normalizeIss(iss) {
|
|||
}
|
||||
return iss.replace(/\/$/, '');
|
||||
}
|
||||
keyfetch._decode = function (jwt) {
|
||||
|
||||
keyfetch.jwt = {};
|
||||
keyfetch.jwt.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')
|
||||
// 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.verify = function (opts) {
|
||||
var jwt = opts.jwt;
|
||||
keyfetch.jwt.verify = function (jwt, opts) {
|
||||
if (!opts) { opts = {}; }
|
||||
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 + "'");
|
||||
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;
|
||||
}
|
||||
if (exp) {
|
||||
valid = (parseInt(exp, 10) - (Date.now()/1000) > 0);
|
||||
if (!valid) {
|
||||
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) {
|
||||
valid = (parseInt(nbf, 10) - (Date.now()/1000) <= 0);
|
||||
if (!valid) {
|
||||
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;
|
||||
|
@ -251,63 +322,28 @@ keyfetch.verify = function (opts) {
|
|||
});
|
||||
})).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;
|
||||
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.");
|
||||
}
|
||||
// old, gotta make sure nothing else uses this
|
||||
keyfetch._decode = function (jwt) {
|
||||
var obj = keyfetch.jwt.decode(jwt);
|
||||
return { header: obj.header, payload: obj.claims, signature: obj.signature };
|
||||
};
|
||||
keyfetch.verify = function (opts) {
|
||||
var jwt = opts.jwt;
|
||||
return keyfetch.jwt.verify(jwt, opts).then(function (obj) {
|
||||
return { header: obj.header, payload: obj.claims, signature: obj.signature };
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -346,3 +382,11 @@ function ecdsaAsn1SigToJwtSig(header, b64sig) {
|
|||
.replace(/=/g, '')
|
||||
;
|
||||
}
|
||||
|
||||
function isTrustedIssuer(issuer) {
|
||||
return function (trusted) {
|
||||
if ('*' === trusted) { return true; }
|
||||
// TODO normalize and account for '*'
|
||||
return issuer.replace(/\/$/, '') === trusted.replace(/\/$/, '') && trusted;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "keyfetch",
|
||||
"version": "1.1.10",
|
||||
"version": "1.2.0",
|
||||
"description": "Lightweight support for fetching JWKs.",
|
||||
"homepage": "https://git.coolaj86.com/coolaj86/keyfetch.js",
|
||||
"main": "keyfetch.js",
|
||||
|
|
Loading…
Reference in New Issue