From a91624bf4bd4545759132c425ffc4f214d6830d0 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 26 Jul 2017 15:24:16 +1000 Subject: [PATCH] Expanded JWT Capabilities --- bin/cli-serve | 4 +- config.js | 51 ++++++++++++++++- services/jwt.js | 81 +++++++++++++++++++++++++++ services/mongoose.js | 2 +- services/passport.js | 21 +++---- test/server/graph/mutations/addTag.js | 1 - webpack.config.js | 3 +- 7 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 services/jwt.js diff --git a/bin/cli-serve b/bin/cli-serve index 46fe51ff9..b04714690 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -90,7 +90,7 @@ async function onListening() { let bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`; - console.log(`API Server Listening on ${bind}`); + debug(`API Server Listening on ${bind}`); } /** @@ -149,7 +149,7 @@ async function startApp(program) { // Mount the websocket server if requested. if (program.websockets) { - console.log(`Websocket Server Listening on ${port}`); + debug(`Websocket Server Listening on ${port}`); // Mount the subscriptions server on the application server. createSubscriptionManager(server); diff --git a/config.js b/config.js index 44747b188..2d8014911 100644 --- a/config.js +++ b/config.js @@ -7,6 +7,8 @@ // entrypoint for the entire applications configuration. require('env-rewrite').rewrite(); +const debug = require('debug')('talk:config'); + //============================================================================== // CONFIG INITIALIZATION //============================================================================== @@ -24,6 +26,8 @@ const CONFIG = { // application. JWT_SECRET: process.env.TALK_JWT_SECRET || null, + JWT_SECRETS: process.env.TALK_JWT_SECRETS || null, + // JWT_AUDIENCE is the value for the audience claim for the tokens that will be // verified when decoding. If `JWT_AUDIENCE` is not in the environment, then it // will default to `talk`. @@ -102,8 +106,53 @@ const CONFIG = { // JWT based configuration //------------------------------------------------------------------------------ +const jwt = require('./services/jwt'); + +if (CONFIG.JWT_SECRETS) { + CONFIG.JWT_SECRETS = JSON.parse(CONFIG.JWT_SECRETS); + if (!Array.isArray(CONFIG.JWT_SECRETS)) { + throw new Error('TALK_JWT_SECRETS must be a JSON array in the form [{"kid": kid, ["secret": secret | "private": private, "public": public]}, ...]'); + } + + if (CONFIG.JWT_SECRETS.length === 0) { + throw new Error('TALK_JWT_SECRETS must be a JSON array with non zero length'); + } + + // Wrap a multi-secret around the available secrets. + CONFIG.JWT_SECRET = new jwt.MultiSecret(CONFIG.JWT_SECRETS.map((secret) => { + if (!('kid' in secret)) { + throw new Error('when multiple keys are specified, kid\'s must be specified'); + } + + // HMAC secrets do not have public/private keys. + if (CONFIG.JWT_ALG.startsWith('HS')) { + return new jwt.SharedSecret(secret); + } + + if (!('public' in secret)) { + throw new Error('all symetric keys must provide a PEM encoded public key'); + } + + return new jwt.AsymmetricSecret(secret); + })); + + debug(`loaded ${CONFIG.JWT_SECRET.length} secrets`); +} else if (CONFIG.JWT_SECRET) { + if (CONFIG.JWT_ALG.startsWith('HS')) { + CONFIG.JWT_SECRET = new jwt.SharedSecret({ + secret: CONFIG.JWT_SECRET + }); + } else { + CONFIG.JWT_SECRET = new jwt.AsymmetricSecret(JSON.parse(CONFIG.JWT_SECRET)); + } + + debug('loaded 1 secret'); +} + if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) { - CONFIG.JWT_SECRET = 'keyboard cat'; + CONFIG.JWT_SECRET = new jwt.SharedSecret({ + secret: 'keyboard cat' + }); } else if (!CONFIG.JWT_SECRET) { throw new Error( 'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens' diff --git a/services/jwt.js b/services/jwt.js new file mode 100644 index 000000000..ad881e926 --- /dev/null +++ b/services/jwt.js @@ -0,0 +1,81 @@ +const jwt = require('jsonwebtoken'); + +/** + * MultiSecret will take many secrets and provide a unified interface for + * handling verifying and signing. + */ +class MultiSecret { + constructor(secrets) { + this.secrets = secrets; + } + + sign(payload, options) { + return this.secrets[0].sign(payload, options); + } + + verify(token, options, callback) { + let header = null; + try { + header = JSON.parse(Buffer(token.split('.')[0], 'base64').toString()); + } catch(err) { + return callback(err); + } + + if (!('kid' in header)) { + return callback(new Error('expected kid to exist in the token header, it did not.')); + } + + let kid = header.kid; + let verifier = this.secrets.find((secret) => secret.kid === kid); + if (!verifier) { + return callback(new Error(`expected kid ${kid} was not available.`)); + } + + return verifier.verify(token, options, callback); + } +} + +class SharedSecret { + constructor({kid = undefined, secret}) { + this.kid = kid; + this.secret = secret; + } + + sign(payload, options) { + return jwt.sign(payload, this.secret, Object.assign({}, options, { + keyid: this.kid + })); + } + + verify(token, options, callback) { + jwt.verify(token, this.secret, options, callback); + } +} + +class AsymmetricSecret { + constructor({kid = undefined, private: privateKey, public: publicKey}) { + this.kid = kid; + this.public = Buffer.from(publicKey.replace(/\\n/g, '\n')); + this.private = privateKey ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null; + } + + sign(payload, options) { + if (!this.private) { + throw new Error('no private key on secret, cannot sign'); + } + + return jwt.sign(payload, this.private, Object.assign({}, options, { + keyid: this.kid + })); + } + + verify(token, options, callback) { + jwt.verify(token, this.public, options, callback); + } +} + +module.exports = { + AsymmetricSecret, + SharedSecret, + MultiSecret +}; diff --git a/services/mongoose.js b/services/mongoose.js index 2b05aaeaa..07f3b2189 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -43,7 +43,7 @@ if (enabled('talk:db')) { if (WEBPACK) { - console.warn('Not connecting to mongodb during webpack build'); + debug('Not connecting to mongodb during webpack build'); // @wyattjoh: We didn't call connect, but because we include mongoose, it will hold the socket ready, // preventing node from exiting. Calling disconnect here just ensures that the application diff --git a/services/passport.js b/services/passport.js index 4ff6198f0..65fa1ac03 100644 --- a/services/passport.js +++ b/services/passport.js @@ -4,7 +4,6 @@ const SettingsService = require('./settings'); const TokensService = require('./tokens'); const fetch = require('node-fetch'); const FormData = require('form-data'); -const JWT = require('jsonwebtoken'); const LocalStrategy = require('passport-local').Strategy; const errors = require('../errors'); const uuid = require('uuid'); @@ -28,13 +27,16 @@ const { // GenerateToken will sign a token to include all the authorization information // needed for the front end. -const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, { - jwtid: uuid.v4(), - expiresIn: JWT_EXPIRY, - issuer: JWT_ISSUER, - subject: user.id, - audience: JWT_AUDIENCE -}); +const GenerateToken = (user) => { + return JWT_SECRET.sign({}, { + jwtid: uuid.v4(), + expiresIn: JWT_EXPIRY, + issuer: JWT_ISSUER, + subject: user.id, + audience: JWT_AUDIENCE, + algorithm: JWT_ALG + }); +}; // SetTokenForSafari sends the token in a cookie for Safari clients. const SetTokenForSafari = (req, res, token) => { @@ -187,7 +189,6 @@ const CheckBlacklisted = async (jwt) => { return checkGeneralTokenBlacklist(jwt); }; -const jwt = require('jsonwebtoken'); const JwtStrategy = require('passport-jwt').Strategy; const ExtractJwt = require('passport-jwt').ExtractJwt; @@ -204,7 +205,7 @@ let cookieExtractor = function(req) { // Override the JwtVerifier method on the JwtStrategy so we can pack the // original token into the payload. JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => { - return jwt.verify(token, secretOrKey, options, (err, jwt) => { + return JWT_SECRET.verify(token, options, (err, jwt) => { if (err) { return callback(err); } diff --git a/test/server/graph/mutations/addTag.js b/test/server/graph/mutations/addTag.js index a17d83847..38a96ad1f 100644 --- a/test/server/graph/mutations/addTag.js +++ b/test/server/graph/mutations/addTag.js @@ -37,7 +37,6 @@ describe('graph.mutations.addTag', () => { console.error(res.errors); } - console.log('res.errors', res.errors); expect(res.errors).to.be.empty; let {tags} = await CommentsService.findById(comment.id); diff --git a/webpack.config.js b/webpack.config.js index 1f4c16ee4..21a70ad93 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -7,6 +7,7 @@ const _ = require('lodash'); const Copy = require('copy-webpack-plugin'); const LicenseWebpackPlugin = require('license-webpack-plugin'); const webpack = require('webpack'); +const debug = require('debug')('talk:webpack'); // Possibly load the config from the .env file (if there is one). require('dotenv').config(); @@ -15,7 +16,7 @@ const {plugins, pluginsPath, PluginManager} = require('./plugins'); const manager = new PluginManager(plugins); const targetPlugins = manager.section('targets').plugins; -console.log(`Using ${pluginsPath} as the plugin configuration path`); +debug(`Using ${pluginsPath} as the plugin configuration path`); const buildTargets = [ 'coral-admin',