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
|
```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
|
||||||
|
|
||||||
|
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
196
keyfetch.js
196
keyfetch.js
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in New Issue