├── .gitignore ├── client ├── app.js └── src │ └── clientRoute.js ├── views ├── partials │ └── pageData.hbs ├── page2.hbs ├── home.hbs └── layout │ └── layout.hbs ├── image └── pirate.jpg ├── data ├── client.json ├── page2.json ├── token.json ├── reauthToken.json └── home.json ├── lib ├── contstants.js ├── utils.js ├── tokenService.js └── endpoints.js ├── plugins ├── static.js ├── cookie.js ├── api.js ├── reverse.js ├── ui.js └── token.js ├── server.js ├── README.md ├── package.json ├── manifest.json └── .eslintrc /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./src/clientRoute')('clientLink'); -------------------------------------------------------------------------------- /views/partials/pageData.hbs: -------------------------------------------------------------------------------- 1 |

{{pageTitle}}

2 | 3 |

{{pageContent}}

-------------------------------------------------------------------------------- /image/pirate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidchase/hapi-yar/master/image/pirate.jpg -------------------------------------------------------------------------------- /data/client.json: -------------------------------------------------------------------------------- 1 | { 2 | "pageTitle": "XHR Request", 3 | "pageContent": "XHR request by the browser." 4 | } -------------------------------------------------------------------------------- /lib/contstants.js: -------------------------------------------------------------------------------- 1 | exports.AUTH_PAYLOAD = 'auth_payload'; 2 | exports.TOKEN_FRESHNESS = 1; // minutes 3 | -------------------------------------------------------------------------------- /views/page2.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{> pageData}} 3 |
4 | 5 |

Home

6 | -------------------------------------------------------------------------------- /data/page2.json: -------------------------------------------------------------------------------- 1 | { 2 | "pageTitle": "Page #2", 3 | "pageContent": "This is a node route handled by the server." 4 | } -------------------------------------------------------------------------------- /data/token.json: -------------------------------------------------------------------------------- 1 | { 2 | "authToken": "1234567890AUTH", 3 | "reauthToken": "0987654321", 4 | "expiresIn": 300 5 | } -------------------------------------------------------------------------------- /data/reauthToken.json: -------------------------------------------------------------------------------- 1 | { 2 | "authToken": "1234567890REAUTH", 3 | "reauthToken": "0987654321", 4 | "expiresIn": 300 5 | } -------------------------------------------------------------------------------- /data/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "pageTitle": "Home", 3 | "pageContent": "My hapi website using a reverse proxy to communicate with web services and control user authentication." 4 | } -------------------------------------------------------------------------------- /views/home.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{> pageData}} 3 |
4 | 5 |

Node route

6 | 7 |

Client route

-------------------------------------------------------------------------------- /views/layout/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{pageTitle}} 5 | 6 | 7 | {{{content}}} 8 | 9 | 10 | -------------------------------------------------------------------------------- /plugins/static.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // static files 4 | exports.register = function(server, options, next) { 5 | server.route({ 6 | method: 'GET', 7 | path: options.path, 8 | handler: { 9 | directory: { 10 | path: options.dirpath, 11 | index: false 12 | } 13 | } 14 | }); 15 | return next(); 16 | }; 17 | 18 | exports.register.attributes = { 19 | name: 'staticFiles' 20 | }; 21 | -------------------------------------------------------------------------------- /plugins/cookie.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AUTH_PAYLOAD = require('../lib/contstants').AUTH_PAYLOAD; 4 | 5 | // simple setup for auth cookie 6 | exports.register = function(server, options, next) { 7 | server.state(AUTH_PAYLOAD, { 8 | ttl: options.ttl, 9 | isSecure: options.isSecure, 10 | path: options.path, 11 | isHttpOnly: options.isHttpOnly, 12 | clearInvalid: options.clearInvalid 13 | }); 14 | return next(); 15 | }; 16 | 17 | exports.register.attributes = { 18 | name: 'authCookie' 19 | }; -------------------------------------------------------------------------------- /plugins/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // handles real services end-point 4 | exports.register = function (server, options, next) { 5 | server.route({ 6 | method: options.method, 7 | path: options.path, 8 | handler: function (request, reply) { 9 | var filename = request.params.p || 'home'; 10 | var filepath = '../data/' + filename + '.json'; 11 | var json = require(filepath); 12 | return reply(json).type('application/json'); 13 | } 14 | }); 15 | return next(); 16 | }; 17 | 18 | exports.register.attributes = { 19 | name: 'apiService' 20 | }; 21 | -------------------------------------------------------------------------------- /plugins/reverse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // proxy for real service end points 4 | exports.register = function(server, options, next) { 5 | server.route({ 6 | method: options.method, 7 | path: options.path, 8 | handler: { 9 | proxy: { 10 | passThrough: true, 11 | localStatePassThrough: true, 12 | mapUri: function (request, callback) { 13 | callback(null, options.proxypath + request.params.p); 14 | } 15 | } 16 | } 17 | }); 18 | return next(); 19 | }; 20 | 21 | exports.register.attributes = { 22 | name: 'reverseProxy' 23 | }; 24 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Hoek = require('hoek'); 4 | var Glue = require('glue'); 5 | var path = require('path'); 6 | 7 | var internals = {}; 8 | 9 | internals.init = function () { 10 | var manifest = require('./manifest.json'); 11 | var manifestOptions = { 12 | relativeTo: path.join(__dirname, './plugins') 13 | }; 14 | Glue.compose(manifest, manifestOptions, function (err, server) { 15 | Hoek.assert(!err, err); 16 | server.start(function (err) { 17 | Hoek.assert(!err, err); 18 | console.log('Server running at:', server.info.uri); 19 | }); 20 | }); 21 | }; 22 | 23 | internals.init(); 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hapi-yar 2 | Basic hapi reverse proxy setup with yar 3 | 4 | ![Hapi-yar](image/pirate.jpg) 5 | 6 | ## Auth Flow 7 | * User visits site for first time, no cookie, generate token payload, serialize, then store in an HTTP only cookie 8 | * User visits site with expired cookie, generate a new token payload, serialize, then and store in an HTTP only cookie 9 | * User visits site with invalid token TOKEN_FRESHNESS threshold but has not expired, pro-actively re-auth the user 10 | * User visits site with invalid token TOKEN_FRESHNESS threshold and is expired, generate a new token payload, serialize, then and store in an HTTP only cookie -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var utils = {}; 4 | 5 | utils.isJSON = function(str) { 6 | try { 7 | JSON.parse(str); 8 | } catch (e) { 9 | return false; 10 | } 11 | return true; 12 | }; 13 | 14 | utils.typeCheck = function(value) { 15 | return ({}).toString.call(value).slice(8, -1).toLowerCase(); 16 | }; 17 | 18 | utils.serialize = function(value) { 19 | if (utils.typeCheck(value) !== 'string') { 20 | value = JSON.stringify(value); 21 | } 22 | return encodeURIComponent(value); 23 | }; 24 | 25 | utils.parse = function(value) { 26 | value = decodeURIComponent(value); 27 | return utils.isJSON(value) ? JSON.parse(value) : value; 28 | }; 29 | 30 | module.exports = utils; -------------------------------------------------------------------------------- /lib/tokenService.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var json = require('../data/token.json'); 4 | var Hoek = require('Hoek'); 5 | var R = require('ramda'); 6 | 7 | var tokenService = { 8 | getAndSetToken: function(err, opts) { 9 | var request; 10 | Hoek.assert(!err, err); 11 | request = { 12 | method: 'POST', 13 | path: '', 14 | headers: { 15 | 'Content-Type': 'application/json' 16 | } 17 | }; 18 | if (opts) { 19 | request = R.assoc('entity', opts, request); 20 | } 21 | return json; 22 | }, 23 | reAuthenticateToken: function(token) { 24 | console.log(token); 25 | return json; 26 | } 27 | }; 28 | 29 | module.exports = tokenService; -------------------------------------------------------------------------------- /lib/endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Hoek = require('hoek'); 4 | 5 | var Endpoints = function() { 6 | this.rest = require('rest'); 7 | this.mime = require('rest/interceptor/mime'); 8 | this.errorCode = require('rest/interceptor/errorCode'); 9 | this.client = this.rest.wrap(this.mime).wrap(this.errorCode); 10 | }; 11 | 12 | Endpoints.prototype.handleErrors = function(err, message, cb) { 13 | message = message || 'Generic error'; 14 | console.error(err, message); 15 | return typeof cb === 'function' ? cb.call(cb, err) : ''; 16 | }; 17 | 18 | Endpoints.prototype.handleSuccess = function(response) { 19 | console.info(response); 20 | return response && response.entity; 21 | }; 22 | 23 | Endpoints.prototype.handleData = function(opt){ 24 | var defaults = { 25 | method: 'GET' 26 | }; 27 | opt = opt || {}; 28 | return this.client(Hoek.applyToDefaults(defaults, opt)); 29 | }; 30 | 31 | module.exports = new Endpoints(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-yar", 3 | "version": "1.0.0", 4 | "description": "Reverse proxy with yar for application authentication", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/clohr/hapi-yar.git" 12 | }, 13 | "keywords": [ 14 | "hapi", 15 | "yar", 16 | "token" 17 | ], 18 | "author": "Christian Lohr ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/clohr/hapi-yar/issues" 22 | }, 23 | "homepage": "https://github.com/clohr/hapi-yar", 24 | "dependencies": { 25 | "glue": "^2.0.0", 26 | "good": "^5.1.2", 27 | "good-console": "^4.1.0", 28 | "handlebars": "^3.0.1", 29 | "hapi": "^8.4.0", 30 | "hoek": "^2.12.0", 31 | "ramda": "^0.13.0", 32 | "rest": "^1.3.0", 33 | "visionary": "^2.0.0", 34 | "when": "^3.7.2", 35 | "wreck": "^5.4.0", 36 | "yar": "^3.0.3" 37 | }, 38 | "devDependencies": { 39 | "browserify": "^9.0.3", 40 | "hbsfy": "^2.2.1", 41 | "nodemon": "^1.3.7" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/clientRoute.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var endpoints = require('../../lib/endpoints'); 4 | var parse = require('../../lib/utils').parse; 5 | 6 | var internals = { 7 | makeXHR: function makeXHR(el) { 8 | var url = el.getAttribute('href') || ''; 9 | var promise = endpoints.handleData({ 10 | method: 'GET', 11 | path: url, 12 | headers: { 13 | 'Content-Type': 'application/json' 14 | } 15 | }); 16 | promise.then(function (resp) { 17 | var template = require('../../views/partials/pageData.hbs'); 18 | var content = document.getElementById('pageContent'); 19 | if (!content) { 20 | console.log('pageContent not found'); 21 | } 22 | content.innerHTML = template(resp.entity); 23 | }).catch(function (err) { 24 | console.log(err); 25 | }); 26 | }, 27 | bindEvents: function bindEvents(elemId) { 28 | var elem = document.getElementById(elemId); 29 | if (!elem) { 30 | return; 31 | } 32 | elem.addEventListener('click', function (e) { 33 | e.preventDefault(); 34 | internals.makeXHR(e.target); 35 | }); 36 | } 37 | }; 38 | 39 | module.exports = function (elemId) { 40 | internals.bindEvents(elemId); 41 | }; 42 | 43 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "connections": [ 3 | { 4 | "port": 9000, 5 | "labels": ["web-ui", "api"] 6 | } 7 | ], 8 | "plugins": { 9 | "./api": { 10 | "method": "GET", 11 | "path": "/api/{p*}" 12 | }, 13 | "./cookie": { 14 | "ttl": 300000, 15 | "isSecure": false, 16 | "path": "/api", 17 | "isHttpOnly": true, 18 | "clearInvalid": true 19 | }, 20 | "good": { 21 | "reporters": [{ 22 | "reporter": "good-console", 23 | "args": [{ 24 | "log": "*", 25 | "response": "*" 26 | }] 27 | }] 28 | }, 29 | "./static": { 30 | "path": "/dist/{filename*}", 31 | "dirpath": "./client/dist" 32 | }, 33 | "./token": null, 34 | "./ui": null, 35 | "visionary": { 36 | "engines": { 37 | "hbs": "handlebars" 38 | }, 39 | "path": "./views", 40 | "layoutPath": "./views/layout", 41 | "partialsPath": "./views/partials", 42 | "layout": true 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /plugins/ui.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Hoek = require('hoek'); 4 | 5 | // web ui 6 | exports.register = function (server, options, next) { 7 | // home 8 | server.route({ 9 | method: 'GET', 10 | path: '/', 11 | config: { 12 | description: 'Returns the homepage', 13 | handler: function (request, reply) { 14 | var endpoints = require('../lib/endpoints'); 15 | var promise = endpoints.handleData({ 16 | 'method': 'GET', 17 | 'path': 'http://localhost:9000/api/home', 18 | 'headers': { 19 | 'Content-Type': 'application/json' 20 | } 21 | }); 22 | promise.then(function (resp) { 23 | return reply.view('home', resp.entity); 24 | }).catch(function (err) { 25 | Hoek.assert(!err, err); 26 | return reply.continue(); 27 | }); 28 | } 29 | } 30 | }); 31 | // page2 32 | server.route({ 33 | method: 'GET', 34 | path: '/page2', 35 | config: { 36 | description: 'Returns a Node route', 37 | handler: function (request, reply) { 38 | var endpoints = require('../lib/endpoints'); 39 | var promise = endpoints.handleData({ 40 | 'method': 'GET', 41 | 'path': 'http://localhost:9000/api/page2', 42 | 'headers': { 43 | 'Content-Type': 'application/json' 44 | } 45 | }); 46 | promise.then(function (resp) { 47 | return reply.view('page2', resp.entity); 48 | }).catch(function (err) { 49 | Hoek.assert(!err, err); 50 | return reply.continue(); 51 | }); 52 | } 53 | } 54 | }); 55 | return next(); 56 | }; 57 | 58 | exports.register.attributes = { 59 | name: 'webUI' 60 | }; 61 | -------------------------------------------------------------------------------- /plugins/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AUTH_PAYLOAD = require('../lib/contstants').AUTH_PAYLOAD; 4 | var TOKEN_FRESHNESS = require('../lib/contstants').TOKEN_FRESHNESS; // minutes 5 | var tokenService = require('../lib/tokenService'); 6 | var serialize = require('../lib/utils').serialize; 7 | var parse = require('../lib/utils').parse; 8 | var R = require('ramda'); 9 | 10 | var internals = { 11 | setUpAuthCookie: function(reply) { 12 | var payload = tokenService.getAndSetToken(null); 13 | var fullPayload = R.compose(R.assoc('createdAt', Date.now()))(payload); 14 | reply.state(AUTH_PAYLOAD, serialize(fullPayload)); 15 | }, 16 | reAuthCookie: function (reply, token) { 17 | var payload = tokenService.reAuthenticateToken(token); 18 | payload.createdAt = Date.now(); 19 | reply.state(AUTH_PAYLOAD, serialize(payload)); 20 | }, 21 | msToMinutes: R.divide(R.__, 60000) 22 | }; 23 | 24 | exports.register = function(server, options, next) { 25 | server.ext('onPreAuth', function(request, reply) { 26 | var token = request && request.state[AUTH_PAYLOAD]; 27 | var parsedToken, tokenLife; 28 | 29 | if (request.path.indexOf('service') < 0) { 30 | return reply.continue(); 31 | } 32 | 33 | if (!token) { 34 | // token does not exist, get a token before continuing action 35 | internals.setUpAuthCookie(reply); 36 | } 37 | 38 | parsedToken = parse(token); 39 | tokenLife = internals.msToMinutes(Date.now() - parsedToken.createdAt); 40 | if (tokenLife > TOKEN_FRESHNESS) { 41 | // token exists and needs to be pro-actively re-authed before continuing action 42 | internals.reAuthCookie(reply, token); 43 | } 44 | 45 | // token exists and is valid and within freshness limit, continue action 46 | return reply.continue(); 47 | }); 48 | 49 | return next(); 50 | }; 51 | 52 | exports.register.attributes = { 53 | name: 'token' 54 | }; 55 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "globals": { 8 | "sinon": true, 9 | "should": true, 10 | "nock": true 11 | }, 12 | "rules": { 13 | "block-scoped-var": 2, 14 | "complexity": [ 15 | 1, 16 | 5 17 | ], 18 | "consistent-return": 2, 19 | "curly": 2, 20 | "default-case": 2, 21 | "dot-notation": 2, 22 | "eqeqeq": 2, 23 | "guard-for-in": 2, 24 | "no-alert": 2, 25 | "no-caller": 2, 26 | "no-comma-dangle": 2, 27 | "no-div-regex": 2, 28 | "no-dupe-keys": 2, 29 | "no-else-return": 2, 30 | "no-empty-label": 2, 31 | "no-eq-null": 2, 32 | "no-eval": 2, 33 | "no-extend-native": 2, 34 | "no-extra-bind": 2, 35 | "no-extra-boolean-cast": 2, 36 | "no-fallthrough": 2, 37 | "no-floating-decimal": 2, 38 | "no-implied-eval": 2, 39 | "no-labels": 2, 40 | "no-iterator": 2, 41 | "no-lone-blocks": 2, 42 | "no-loop-func": 2, 43 | "no-multi-str": 2, 44 | "no-native-reassign": 2, 45 | "no-new": 2, 46 | "no-new-func": 2, 47 | "no-new-wrappers": 2, 48 | "no-octal": 2, 49 | "no-octal-escape": 2, 50 | "no-proto": 2, 51 | "no-redeclare": 2, 52 | "no-return-assign": 2, 53 | "no-script-url": 2, 54 | "no-self-compare": 2, 55 | "no-sequences": 2, 56 | "no-unused-expressions": 2, 57 | "no-unused-vars": 1, 58 | "no-unreachable": 2, 59 | "no-void": 2, 60 | "no-with": 2, 61 | "radix": 2, 62 | "vars-on-top": 2, 63 | "wrap-iife": [ 64 | 2, 65 | "outside" 66 | ], 67 | "yoda": 2, 68 | "quotes": 0, 69 | "eol-last": 0, 70 | "no-extra-strict": 2, 71 | "camelcase": 0, 72 | "consistent-this": [ 73 | 2, 74 | "_this" 75 | ], 76 | "new-cap": 2, 77 | "new-parens": 2, 78 | "func-style": [ 79 | 2, 80 | "expression" 81 | ], 82 | "no-lonely-if": 2, 83 | "no-new-object": 2, 84 | "no-space-before-semi": 2, 85 | "brace-style": [ 86 | 2, 87 | "1tbs" 88 | ], 89 | "no-wrap-func": 2, 90 | "space-after-keywords": [ 91 | 2, 92 | "always" 93 | ], 94 | "use-isnan": 2, 95 | "valid-jsdoc": [ 96 | 2, 97 | { 98 | "prefer": { 99 | "return": "returns" 100 | } 101 | } 102 | ], 103 | "max-nested-callbacks": [2, 3], 104 | "semi": [2, "always" ], 105 | "no-underscore-dangle": 0 106 | } 107 | } --------------------------------------------------------------------------------