diff --git a/README.md b/README.md index 8b3d5ea..e6e3906 100644 --- a/README.md +++ b/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 diff --git a/keyfetch-test.js b/keyfetch-test.js index 55ad7f1..4a53ff0 100644 --- a/keyfetch-test.js +++ b/keyfetch-test.js @@ -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; } +} diff --git a/keyfetch.js b/keyfetch.js index b1cb6a3..b4a95b5 100644 --- a/keyfetch.js +++ b/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; + }; +} diff --git a/package.json b/package.json index cc3d73a..8d23a13 100644 --- a/package.json +++ b/package.json @@ -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",