├── .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 | 
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 | }
--------------------------------------------------------------------------------