hapijs / nes

WebSocket adapter plugin for hapi routes
Other
503 stars 87 forks source link

Question: where can i find an example of server to server with a token? #147

Closed ADumaine closed 7 years ago

ADumaine commented 8 years ago

I have been trying to piece together from all the samples I have found so far but still have not found a full example.
I'd like to use nes and jason web tokens to communicate with other nodejs servers. I have tried using direct and token in auth strategies, but still not sure how to pass either credentials on a client.connect or pass a pre-existing token. The examples seem to use cookies to store the token and I an not sure how that would work in another nodejs app.

Any links to working examples would be appreciated as would updating the examples here to show how it is done (authentication and subsequent requests from the client). Thanks!

hueniverse commented 8 years ago

I'll leave this open in case someone has the time to answer. Unfortunately I don't.

johnbrett commented 8 years ago

Hi @ADumaine. Try posting code for stuff you've tried with things like this, often you're just marginally off and just need some tweaking :) Have you tried looked at the tests? Those are the best source of examples for stuff like this, and what I used to figure out auth in nes. Here's an example of one using a direct auth strategy: https://github.com/hapijs/nes/blob/master/test/auth.js#L662-L702

ADumaine commented 8 years ago

@johnbrett, I was using that test along with the other issues/questions here and from other sites to try to get this to work. I've been through the documentation multiple times. I agree there just must be something minor I am missing or have misconfigured (or maybe it just won't work in my case). I will post the token method I have tried along with the results. I'll also include the output of the client to help debug. It may be a bit long (and messy), but I'll try to keep it focused on configuration and authentication items.

Server 1 - has two connections (web and backend). The backend provides data (api) to server 2(client). node 4.5 on win10 hapi versions

├── hapi@15.0.1
├── hapi-auth-basic@4.2.0
├── hapi-auth-cookie@6.1.1
├── hapi-auth-jwt2@7.1.2
├── hapi-mongo-models@5.0.0
├── hapi-react-views@9.1.1
├── hapi-sequelize@2.2.4

Config

 server: {
        debug: {
            request: ['error']
        },
        connections: {
            routes: {
                security: true
            }
        }
    },
    connections: [{
                       port: Config.get('/port/web'),
                       labels: ['web']
                 },
        {
            port: Config.get('/port/backend'),
            labels: ['backend']
        }
    ],
.......//registrations.  botmgr has the routes to backend connection.  other files point to web.
{
      plugin: {
                register: './server/api/botmgr',
                options: {
                    basePath: '/api',
                }
            },
            options: {
                select: ['backend']
            }
        },
           plugin: {
           register: "nes",
                options: {
                    auth: {
                        type: 'token',
                        password: Config.get('/cookieSecret')
                    },
                },
            },
            options:{
                 select: ['backend']
                }
.......

Auth for backend connection

    server.select(options.select).auth.strategy('jwt', 'jwt', {
       key: Config.get('/cookieSecret'),
           validateFunc: function (decodedToken, callback) {
                         console.log('jwt validate function')
              console.log(decodedToken);
               callback();
           }
        });

  server.select(options.select).auth.strategy('simple', 'basic', {
      validateFunc: function (request, username, password, callback) {
            Async.auto({           
                user: function ( done) {
                var User = request.server.plugins['hapi-mongo-models'].User;
                    User.findByCredentials(username, password, function (err, user) {
                        if (err) {
                            return done(err);
                        }                       
                        done(null, user);           
                    });
                },
                roles: ['user', function (results, done) {
                    if (!results.user) {
                        return done();
                    }
                    results.user.hydrateRoles(done);
                }],
                scope: ['user', function (results, done) {

                    if (!results.user || !results.user.roles) {
                        return done();
                    }

                    done(null, Object.keys(results.user.roles));
                }]
            }, function (err, results) {
                if (err) {
                    return callback(err);
                }
                callback(err, Boolean(results.user), results);
            });
        }

Server routes:

server.route({
    method: 'GET',
    path: options.basePath + '/mgr/login',
        config: {
            auth: {mode: 'required',
                strategy: 'simple'},        
        },
         handler: function (request, reply) {
        if (!request.auth.credentials) {
                reply({ success: false, message: 'Authentication failed. User not found.' });
        } else {
                console.log(request.auth.credentials);
                      // create a token
                     var token = jwt.sign(request.auth.credentials.user._id, Config.get('/cookieSecret'), {});
                     // return the information including token as JSON
                     reply({
                        success: true,
                        message: 'Enjoy your token!',
                        token: token
                    });
                 }   

    }
});

server.route({
        method: 'GET',
        path: options.basePath + '/mgr/list',
        config: {
            id:'list',              
                       auth: {
                   strategy: 'jwt'
                         }
        },
        handler: function (request, reply) {
        console.log("HANDLE MGR LIST");
        var User = request.server.plugins['hapi-mongo-models'].User;
             User.findByUsername('root',  function (err, results) {
                    if (err) {
                        return reply(err);
                   }
                    reply(results);
            });
        }
    });

This is the other server (client):

var Auto = require('async/auto');
var Nes = require('nes');
var querystring = require('querystring');
var debug = require('debug')('nes');

var client = new Nes.Client('localhost:8001');
var http = require('http');
var postData = querystring.stringify({
  'msg' : 'How about a token?'
});

var reqOptions={
    hostname:'localhost',
    port: '8001',
    path: '/api/mgr/login',
    method: 'GET',
    headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Content-Length': Buffer.byteLength(postData)
       },
    auth: 'root:biteme'
}

Auto ({
    login: function(done){
         var response= {};
        var req = http.request(reqOptions, (res) => {
          console.log(`STATUS: ${res.statusCode}`);
          console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
          res.setEncoding('utf8');
          res.on('data', (chunk) => {
            console.log(`BODY: ${chunk}`);  
            response = JSON.parse(chunk);
            debug(response);
          });
          res.on('end', () => {
            console.log('No more data in response.')
            done(null, response);
          })
        });

        req.on('error', (e) => {
          console.log(`problem with request: ${e.message}`);
          done(e);
        });

        // write data to request body
        req.write(postData)
        req.end();

        },

        wsClient: ['login', function(results, done){
            console.log("CONNECT WITH NES CLIENT");
            client.connect( {auth: results.login.token}, function (err) { 
               client.request('/api/mgr/list', function (err, payload) {   // Can also request '/h' 
                   // payload -> 'world!' 
                    debug(err)
                  debug(payload);
                 });
            });
        }]  
    },
    function(err, results){
        console.log(results);
        if(err) 
            return console.log(err)

        console.log( results.login.token)
    }
    )

This is the output of the client. It is able to login and authenticate with basic, return the jwtoken, but fails at returning the token to the server.

STATUS: 200
HEADERS: {"content-type":"application/json; charset=utf-8","strict-transport-security":"max-age=15768000","x-frame-options":"DENY","x-xss-protection":"1; mode=block","x-download-options":"noopen","x-content-type-options":"nosniff","set-cookie":["crumb=YfSbcLhKJCOVHEevuLGwgWsNj2a2KEP9RkBE5LRM1wt; Secure; HttpOnly; SameSite=Strict; Path=/"],"cache-control":"no-cache","content-length":"289","vary":"accept-encoding","accept-ranges":"bytes","date":"Thu, 01 Sep 2016 17:05:08 GMT","connection":"close"}
BODY: {"success":true,"message":"Enjoy your token!","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfYnNvbnR5cGUiOiJPYmplY3RJRCIsImlkIjp7InR5cGUiOiJCdWZmZXIiLCJkYXRhIjpbODcsMTgyLDIsMjA1LDcsMjAxLDY4LDEwMiw0NCwxMjYsMTA5LDg3XX0sImlhdCI6MTQ3Mjc0OTUwOH0.FYrgkLTzPzfXxWeFCQcPePfHb-kgRBspgVE1hzjUlwg"}
  nes { success: true, message: 'Enjoy your token!', token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfYnNvbnR5cGUiOiJPYmplY3RJRCIsImlkIjp7InR5cGUiOiJCdWZmZXIiLCJkYXRhIjpbODcsMTgyLDIsMjA1LDcsMjAxLDY4LDEwMiw0NCwxMjYsMTA5LDg3XX0sImlhdCI6MTQ3Mjc0OTUwOH0.FYrgkLTzPzfXxWeFCQcPePfHb-kgRBspgVE1hzjUlwg' } +0ms
No more data in response.
CONNECT WITH NES CLIENT
  nes Error: Failed to send message - server disconnected
    at new NesError (C:\Data\Bottrader_online\node_modules\nes\lib\client.js:77:19)
    at Client._connect.ws.onopen.ws.onerror.Client._cleanup.Client._reconnect.Client._send.callback [as _send] (C:\Data\Bottrader_online\node_modules\nes\lib\client.js:415:39)
    at Client._connect.ws.onopen.ws.onerror.Client._cleanup.Client._reconnect.Client.request (C:\Data\Bottrader_online\node_modules\nes\lib\client.js:395:21)
    at C:\Data\Bottrader_online\wstest.js:97:11
    at finalize (C:\Data\Bottrader_online\node_modules\nes\lib\client.js:188:24)
    at WebSocket.ws.onopen.ws.onerror (C:\Data\Bottrader_online\node_modules\nes\lib\client.js:233:20)
    at WebSocket.onError (C:\Data\Bottrader_online\node_modules\nes\node_modules\ws\lib\WebSocket.js:452:14)
    at emitOne (events.js:77:13)
    at WebSocket.emit (events.js:169:7)
    at ClientRequest.onerror (C:\Data\Bottrader_online\node_modules\nes\node_modules\ws\lib\WebSocket.js:711:10) +2s
  nes undefined +22ms

If I use the browser: http://localhost:8001/nes/auth Result is: {"status":"unauthenticated"}

If I try ws in the browser: ws://localhost:8001/nes/auth or ws://localhost:8001/ Result is: site can't be reached

I have also tried direct and cookie https://github.com/hapijs/nes/issues/75 https://gist.github.com/kelkes/30eec9bf98d03235d38f327b6b66518f?ts=4

ADumaine commented 8 years ago

So I am a couple steps further with this but still have an issue.

One of the errors I was making is that I thought using nes token meant you could use your own token if available. But apparently that is how direct works (after reading the docs for the 10th time) per issue #5. I realized the token returned to the client from nes is signed with iron and is not in valid jwt format.

Here are the configs and steps with nes configured for "token":

            plugin: {
                register: "nes",
                options: {
                    auth: {
                        type: 'token',
                        route: 'simple'
                    },
                },
            },

Create a basic authentication scheme on the server ( this used async auto)

  server.select(options.select).auth.strategy('simple', 'basic', {
      isSecure: false,
      validateFunc: function (request, username, password, callback) {
            Async.auto({    
                user: function ( done) {
            var User = request.server.plugins['hapi-mongo-models'].User;
                    User.findByCredentials(username, password, function (err, user) {

                        if (err) {
                            return done(err);
                        }           
                        done(null, user);           
                    });
                },
                roles: ['user', function (results, done) {
                    if (!results.user) {
                        return done();
                    }
                    results.user.hydrateRoles(done);
                }],
                scope: ['user', function (results, done) {
                    if (!results.user || !results.user.roles) {
                        return done();
                    }
                    done(null, Object.keys(results.user.roles));
                }]
            }, function (err, results) {
                if (err) {
                    return callback(err);
                }
                callback(err, Boolean(results.user), results);
            });
        }
  });

On the client, make a call to the /nes/auth path with credentials to authenticate. Then pass the resulting token to the client.connect() in an auth object.

var Auto = require('async/auto');
var Nes = require('nes');
var querystring = require('querystring');
var debug = require('debug')('nes');
var client = new Nes.Client('ws://localhost:8001');
var http = require('http');

var postData = querystring.stringify({
  'msg' : 'How about a token?'
});

var up = new Buffer("root:biteme").toString('base64')
var reqOptions={
    hostname:'localhost',
    port: '8001',
    path: '/nes/auth',
    method: 'GET',
    headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': Buffer.byteLength(postData),
    'Authorization': 'Basic ' + up
    },
}
Auto ({
    login: function(done){
        var response= {};
        var req = http.request(reqOptions, (res) => {
          console.log(`STATUS: ${res.statusCode}`);
          console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
          res.setEncoding('utf8');
          res.on('data', (chunk) => {
            console.log(`BODY: ${chunk}`);      
            response = JSON.parse(chunk);
          });
          res.on('end', () => {
            console.log('No more data in response.')
            done(null, response);
          })
        });
        req.on('error', (e) => {
          console.log(`problem with request: ${e.message}`);
          done(e);
        });

        // write data to request body
        req.write(postData)
        req.end();
        },
        wsClient: ['login', function(results, done){
            console.log("CONNECT WITH NES CLIENT");
            client.connect({auth: results.login.token},  function (err) {
                client.request('/api/mgr/list', function (err, payload) {   
                    debug(err)
                    debug(payload);
                    return done(err, payload)
                });
                if(err) debug(err)
            });
        }],     
    },
    function(err, results){
        console.log(results);
        if(err) {
            return console.log(err)
        }
    }
    )

A nes token is returned and then passed to the client for subsequent calls via a client.request to an hapi route (not using subscriptions).

HOWEVER, it only appears to work when making a call to route with a config.auth = false. Which is no real use in my case.

With a route configured with the strategy and a scope, the scope fails. { statusCode: 403, error: 'Forbidden', message: 'Insufficient scope' } It is as if the credentials are not available to the route. I don't know what else to try to get the scope from the credentials to be recognized.

When switched back to false the credentials can be sent to the console and show the user and scope but it is not authenticated.

{ isAuthenticated: false,
  credentials: { credentials: { user: [Object], scope: [Object], roles: [Object] } },
  artifacts: null,
  strategy: null,
  mode: null,
  error: null }

This is the route. The only strategy available is 'simple'

server.route({
        method: 'GET',
        path: options.basePath + '/mgr/list',
        config: {
        id:'list',
        auth: { 
                strategy: 'simple',
                scope: ['admin']
            },
        },
        handler: function (request, reply) {
        console.log("HANDLE MGR LIST");
        console.log(request.auth);
        var User = request.server.plugins['hapi-mongo-models'].User;
                // query hard coded for testing
                User.findByUsername('root',  function (err, results) {
                    if (err) {
                        return reply(err);
                    }
                reply({"_id" : results._id});
            });
        }
    });

I do not see an instance in the tests that use the combination of token and scope with a client request.

So what am I missing?

johnbrett commented 8 years ago

Hey @ADumaine. What your describing sounds like you should be using direct auth, not token, for use with jwt.

Have you got a very simple use case from the browser (just as it's the simple use case), where the initial basic auth returns the jwt token, which you can make normal REST requests with?

Once you get there, again from the browser, check you can connect to the server with the jwt token?

If you narrow it down to that you'll probably find the issue, but right now your use case is too large to debug.

ADumaine commented 8 years ago

@johnbrett, I could not get 'token' type to work with routes that have a scope. Maybe someone could double check the auth tests and add a test using token on a route with scope.

My use case is not from a browser but from another node server. I did not want to introduce any actions a browser may add (like cookies and headers).

I got direct working with basic auth and it works with scoped routes called from client.request.

Once I got that working I added jwt tokens using hapi-auth-jwt2. I finally got this working too. The basic sequence: use an http call and basic auth to login in, generate a token and return it use the token in the nes client to connect to a path using a jwt authentication strategy

One of my issues was that I was not passing the token back correctly in the client. I was using an incorrect object as it shows in the tests, but now see this is for token only: {auth: token} The correct way is to pass it as an authorization header

client.connect({auth: {headers: {authorization: jwttoken}}},  function (err) { //....

There was another tweak to the auth strategy from above (there was a missing parameter to the validate function). Ignore the reference to cookie in the key, it is just a secret string shared with the token generator :

server.select(options.select).auth.strategy('jwt', 'jwt', {
     key: Config.get('/cookieSecret'),
        validateFunc: function (decodedToken, request, callback) {
                  //..... user validation ......//
          callback(null, true, decodedToken);
        }
    });

See the server route for /mgr/login for generating the token. It uses 'var jwt = require('jsonwebtoken')'

At this point I may just revert to nes direct with basic auth since it removes the login step to get a token and is less complex. Nes just takes care of the connection and client requests.

I really wish there were some examples for token and for direct using your own token. Or at least some clearer explanation in the docs in the auth sections.

I hope this helps someone else.

shanerowatt commented 8 years ago

@ADumaine I got a node server working with JWT + NES by registering the 'nes' module with an auth.route set to the name of the server.auth.stratergy I registered (i.e. 'jwt') and auth.type set to 'direct'.

First you need to register the jwt auth module:

const AuthBearer = require('hapi-auth-bearer-token');
server.register(AuthBearer, function (err) {
    if (err) {
        server.log(['error'], 'hapi-auth-bearer-token load error: ' + err);
    } else {
        server.auth.strategy('jwt', 'bearer-access-token', {
            allowQueryToken: false,
            allowMultipleHeaders: false,
            tokenType: 'Bearer',
            validateFunc: function (token, callback) {
                // put your jwt validation code in here
            }
        }
    }
});

and then register the 'nes' module:

var hapiNes = require('nes');
    server.register({
        register: hapiNes,
        options: {
            auth: {
                route: 'jwt',
        type: 'direct'
            }
        }
    }, function (err) {
        if (err) {
            logger.log(null, null, 'hapi-nes load error: ' + err, server);
        }  else {
            server.subscription('/ops/live_tail', {
                auth: {
                    mode: 'required'
                }
            });
            logger.log(null, null, 'HAPI NES available', server);
        }
});

Then the client connects and calls the /ops/live_tail endpoint like this:

var Nes = require('nes');
var config = require('config');
require('colors');
var token_utils = require('./lib/token_utils');

var jwt = token_utils.get_admin_token();

var client = new Nes.Client('ws://localhost:4000');
client.connect({
    auth: {
        headers: {
            authorization: 'bearer ' + jwt
        }
    }
},function (err) {
    if (err) {
    console.log(((err.data.error + "(" + err.statusCode + "): ").bold  + (err.data.message)).red);
} else {
        var handler = function (update, flags) {
            console.log(update);
       };

        client.subscribe('/ops/live_tail', handler, function (err) {
            console.log(err);
        });
    }
});
ADumaine commented 8 years ago

@shanerowatt that is very similar to how I got it working with jwt and hapi-auth-jwt2.

Your example shows how to implement subscriptions, which nes handles. I needed to make client requests to existing api routes that have scope that is determined by the credentials. That is what tripped me up when trying to use nes token. The scopes worked fine with the nes direct.

quirm commented 8 years ago

I'm probably facing a similar problem. It seems that Nes is ignoring "auth" param per route but it works fine when auth is required globally.

When I do this:

Server side

server.auth.strategy('jwt', 'jwt', {
  key: config.get('JWT_SECRET'),
  verifyOptions: {
    ignoreExpiration: true
  },
  validateFunc: async (decoded, request, callback) => {
    ...
  }
});

server.route([{
  method: 'GET',
  path: '/h',
  handler: (request, reply) => {
    reply(`hello ${request.auth.credentials.username}`);
  },
  config: {
    auth: {
      strategy: 'jwt',
      mode: 'required' // no matter what I set in here
    }
  }
}])

Client side

const client = new Nes.Client(`ws://${API_HOST}`);
const options = { auth: { headers: { authorization: `Bearer ${this.token}` } } };

client.connect(options, (err) => {
  client.request('/h', (err, payload) => {
    ...
  });
});

However, when I remove the whole config part from a route and just apply 'required' for a strategy configuration it works as expected. Is there a way to do it without setting one particular strategy as 'required' for all routes?

server.auth.strategy('jwt', 'jwt', 'required', { ...})
quirm commented 8 years ago

Ok, few more experiments later and I got it working, thanks @shanerowatt for your snippet.

I'm not very happy how I've resolved it because I need to register plugins twice to make this work:

// plugins is an array of plugins and their config from a different file
server.register(plugins, (err) => {
  if (err) {
    log.error(err);
    return reject('There was a problem loading hapi plugin');
  }

  // This function registers several strategies and also the one which is required by Nes. 
  // I can't call it before because Bell is being registered above.
  strategiesSetup(server); 
  ...

  // I'm safe to register Nes after strategies are in place :/
  server.register({
    register: Nes,
    options: {
      auth: {
        type: 'direct',
        route: 'jwt',
        isSecure: false
      }
    }
  });

  ...
}

Is it common to call server.register more than once?

lock[bot] commented 4 years ago

This thread has been automatically locked due to inactivity. Please open a new issue for related bugs or questions following the new issue template instructions.