Search code examples
node.jssessioncookiesproxyhapi.js

HapiJS Proxy Trouble


TL;DR

How do I intercept a request, ping a different route for an ID, store the ID in a session, then continue the original request (especially PUT/POST with payloads) using the ID I just got?

Background

I am using HapiJS (8) to proxy requests from the client to an existing API (where I have no control over processes/logic). The API requires each request to contain a 'session ID' in either the query string or the payload (depending on the http method). In order to get a session ID, all I have to do is ask for one... there is no username/pwd required (it uses Basic auth in the headers). The session ID expires every 24 hours if it isn't renewed. Each client has their own session ID.

I am currently using hapi-auth-cookie to store the value of the session ID which gets queried when the ID is needed. If the ID is expired or null, I need to request a new one before the client's request can be successfully proxy'd to the API.

Current Solution

When the client's request method is 'GET', I am handling this challenge quite gracefully utilizing the appendNext configuration described in the hapi-auth-cookie docs. The request is intercepted by hapi-auth-cookie, if a new session ID is needed, a request is sent to that particular route to get it, the API returns the ID which is then assigned to the Hapi session, and then (using Wreck) a reply.redirect returns to the original GET request where it completes. Seamless and graceful.

However, I cannot figure out how to accomplish this same flow with different http methods that contain payloads of data.

Is there something besides reply.redirect that will accomplish the same goal while maintaining original payloads and methods? Or is there a much better way to do this in general?

The Code (for what currently works for 'GET' requests)

main app file (hapi-auth-cookie configs)

# register plugins
server.register require('./plugins')
, (err) ->
    throw err if err

    # set authentication (NLS) configs
    server.auth.strategy 'session', 'cookie',
        password: 'session_pwd'
        cookie: 'ghsid'
        redirectTo: '/api/SessionID' #get new session ID
        isSecure: config.get 'ssl'
        appendNext: true
        ttl: config.get 'session_length'

Controller that leverages session authentication and invokes hapi-auth-cookie plugin:

simpleRequest:
    auth: 'session'
    handler: (request, reply) ->
        qs = Qs.stringify request.query
        request.papi_url = "/api/route/sample?#{qs}"

        reply.proxy
            mapUri: (request, reply) ->
                auth = config.get 'basic_auth'
                api_host = config.get 'api_host'
                papi_url = request.papi_url
                path = api_host + papi_url
                next null, path, {authorization: auth}

Route for getting a new session ID

module.exports = [

    {
        path: '/api/SessionID'
        method: 'GET'
        config: SessionController.session
    }

]

Session Controller

Wreck       = require 'wreck'
config      = require 'config'

module.exports =
    session:
        description: 'Get new session ID'

        auth:
            mode: 'try'
            strategy: 'session'

        plugins:
            'hapi-auth-cookie':
                redirectTo: false

        handler: (request, reply) ->

            # request configs
            papi_url = "/Session"
            api_host = config.get 'api_host'
            url = api_host + papi_url
            opts =
                headers:
                    'Authorization': config.get 'basic_auth'
                    'content-type': 'application/json;charset=UTF-8'

            # make request to PAPI
            Wreck.post url, opts, (err, res, body) ->
                throw new Error err if err

                try
                    bdy = JSON.parse body
                    sess =
                        nls: bdy.SessionId

                    if bdy.SessionId
                        # authenticate user with NLS
                        request.auth.session.set sess

                        # redirect to initial route
                        reply.redirect request.url.query.next

                    else
                        return throw new Error

                catch err
                    throw new Error err

Final solution

Based on Matt Harrison's answer, I created a custom plugin that gets registered as an authentication scheme so I can control this per route.

Here's the plugin code:

Wreck           = require 'wreck'
config          = require 'config'

exports.register = (server, options, next) ->
    server.auth.scheme 'cookie', internals.implementation
    next()

exports.register.attributes =
    name: 'Hapi Session Interceptor'
    version: '1.0.0'

internals = {}

internals.implementation = (server, options, next) ->

    scheme = authenticate: (request, reply) ->

        validate = ->

            session = request.state.sessionID
            unless session
                return unauthenticated()

            reply.continue(credentials: {'session': session})

        unauthenticated = ->

            api_url = "/SessionID"
            api_host = config.get 'api_host'
            url = api_host + api_url
            opts =
                headers:
                    'Authorization': config.get 'basic_auth'
                    'content-type': 'application/json;charset=UTF-8'

            # make request to API
            Wreck.post url, opts, (err, res, body) ->
                throw new Error err if err

                bdy = JSON.parse body
                sess =
                    session: bdy.SessionId

                if bdy.SessionId
                    reply.state 'sessionID', bdy.SessionId
                    reply.continue(credentials: sess)

                else
                    return throw new Error

        validate()

    return scheme

Solution

  • Although not entirely faithful to your code, I've put together an example that has all the pieces I think you're working with.

    I've made a service plugin to represent your API. The upstream plugin represents the actual upstream API you're proxying to.

    All requests passthrough the service and are proxied to the upstream, which just prints out all of the headers and payload it received.

    If the original request doesn't contain a cookie with a sessionId, a route is hit on the upstream to get one. A cookie is then set with this value when the response comes back down stream.

    The code is here: https://github.com/mtharrison/hapijs-proxy-trouble

    Try it out with curl and your browser.

    GET: curl http://localhost:4000

    POST W/PAYLOAD: curl -X POST -H "content-type: application/json" -d '{"example":"payload"}' http://localhost:4000

    index.js

    var Hapi = require('hapi');
    
    var server = new Hapi.Server();
    
    server.connection({ port: 4000, labels: ['service'] }); // Your service
    server.connection({ port: 5000, labels: ['upstream']}); // Pretend upstream API
    
    server.state('session', {
        ttl: 24 * 60 * 60 * 1000,
        isSecure: false,
        path: '/',
        encoding: 'base64json'
    });
    
    server.register([{
        register: require('./service')
    }, {
        register: require('./upstream')
    }], 
    function (err) {
    
        if (err) {
            throw err;
        }
    
        server.start(function () {
    
            console.log('Started!');
        });
    
    });
    

    service.js

    var Wreck = require('wreck');
    
    exports.register = function (server, options, next) {
    
        // This is where the magic happens!
    
        server.select('service').ext('onPreHandler', function (request, reply) {
    
            var sessionId = request.state.session;
    
            var _done = function () {
    
                // Set the cookie and proceed to the route
    
                request.headers['X-Session-Id'] = sessionId;
                reply.state('session', sessionId);
                reply.continue();
            }
    
            if (typeof sessionId !== 'undefined')
                return _done();
    
            // We don't have a sessionId, let's get one
    
            Wreck.get('http://localhost:5000/sessionId', {json: true}, function (err, res, payload) {
    
                if(err) {
                    throw err;
                }
    
                sessionId = payload.id;
    
                _done();
            });
        });
    
        server.select('service').route({
            method: '*',
            path: '/{p*}',  // Proxies all routes and methods
            handler: {
                proxy: {
                    host: 'localhost',
                    port: 5000,
                    protocol: 'http',
                    passThrough: true
                }
            }
        });
    
        next();
    };
    
    exports.register.attributes = {
        name: 'your-service'    
    };
    

    upstream.js

    exports.register = function (server, options, next) {
    
        server.select('upstream').route([{
            method: '*',
            path: '/{p*}',
            handler: function (request, reply) {
    
                // Just prints out what it received for headers and payload
                // To prove we got send the original payload and the sessionID header
    
                reply({
                    originalHeaders: request.headers,
                    originalPayload: request.payload,
                })
            }
        }, {
            method: 'GET',
            path: '/sessionId',
            handler: function (request, reply) {
    
                // Returns a random session id
    
                reply({ id: (Math.floor(Math.random() * 1000)) });
            }
        }]);
    
        next();    
    };
    
    exports.register.attributes = {
        name: 'upstream'    
    };