We're live-coding on Twitch! Join us!
Build a Secure Node.js Application with JavaScript Async Await Using Hapi

Build a Secure Node.js Application with JavaScript Async Await Using Hapi

At the core of the JavaScript language is its asynchronous programming model. Unfortunately, dealing with callback functions has long been a source of frustration for many developers. JavaScript Promises helped make writing complex asynchronous code more manageable, but brought its own set of challenges. With the introduction of async functions in ES2017 (and the async and await keywords), writing asynchronous JavaScript is now much easier.

Hapi is one of many frameworks available for Node.js designed for building scalable web application and services. With the release of version 17, hapi has been completely overhauled to use JavaScript async functions. The result is a modern framework for Node.js that is a pleasure to use.

Most Node.js tutorials available today were written for older versions of Node.js using older ES5 syntax. In this tutorial, you will learn the basics of creating a Node.js web application with hapi using more modern JavaScript.

Create Your First hapi Web Server

Open up a terminal (Mac/Linux) or a command prompt (Windows) and type the following command:

node --version

If you get an error, or the version of Node.js you have is less than version 8, you'll need to install Node.js. On Mac or Linux, I recommend you first install nvm and use nvm to install Node.js. On Windows, I recommend you use Chocolatey.

After ensuring you have a recent version of Node.js installed, create a folder for your project.

mkdir learning-hapi
cd learning-hapi

A package.json file is required for your Node.js project and includes things like project information, scripts, and project dependencies. Use the npm command to create a package.json file in the project folder.

npm init -y

Next, install hapi as a dependency.

Essential Reading: Learn React from Scratch! (2019 Edition)
npm install hapi

Now open the project in your editor of choice.

If you don't already have a favorite code editor, I recommend installing Visual Studio Code. VS Code has exceptional support for JavaScript and Node.js, such as smart code completion and debugging. There's also a vast library of free extensions contributed by the community.

Next, create a folder named src. In this folder, add a new file named index.js. Open the file and add the following JavaScript.

"use strict";

const Hapi = require( "hapi" );

const port = 8080;
const server = Hapi.server( { port } );

// Define a route for the URL http://localhost:8080/
server.route( {
    method: "GET",
    path: "/",
    handler: () => {
        // a handler can return text, HTML, JSON,
        // a file, or just about anything.
        return "My first hapi server!";
    }
} );

const start = async () => {
    try {
        // start the server
        await server.start();
        console.log( `Server running at http://localhost:${ port }` );
    } catch ( err ) {
        console.log( err );
        process.exit( 1 );
    }
};

start();

As you can see in the previous code, the line const start = async () => { declares an asynchronous function named start using the arrow function syntax. server.start() is itself an asynchronous function, which requires the await keyword. The await keyword instructs the application to pause execution until the async function completes before moving on to the next line of code.

Dealing with errors in asynchronous code before async/await was tricky at best. Another advantage of using async/await is the ability to use straight-forward try/catch blocks to catch any errors that may occur.

Next, edit the package.json file and change the "main" property value to "src/index.js". This property points Node to a file to execute when the application starts.

  "main": "src/index.js",

Now you can start the application. Go back to the terminal window and type in the following command.

node .

You should see the message Server running at http://localhost:8080. Open your browser and navigate to http://localhost:8080. Your browser should display something like the following.

Success! You are on your way to hapi-ness!

Hapi Project Structure

As a Node.js project grows beyond a simple "hello world" example, it's essential to set up a good project structure. There are countless opinions on how you might organize a project, but a good starting point for a web application might look something like the following.

├── package.json
├── client
│   ├── index.html
│   ├── css
│   └── js
├── src
│   ├── app.js
│   ├── index.js
│   ├── plugins
│   │   └── index.js
│   ├── routes
│   │   └── index.js
│   └── views
└── test
    └── index.js

Hapi can serve static files, such as HTML, CSS, and front-end JavaScript, using the inert plugin (more on plugins later). The client folder is where you might store these static assets. Of course, the contents and structure inside the client folder may differ depending on your front-end framework of choice.

Under the src folder, you might organize your server-side code into the following files and folders:

  • app.js to configure the hapi server,
  • index.js to start the server,
  • plugins for registering external and custom hapi plugins,
  • routes for defining the resources, or URIs, of your application,
  • and views for any back-end dynamically-rendered content.

Hapi can render server-side content using the vision plugin combined with a template engine such as ejs, handlebars, or pug. It is up to you whether you want your application to serve static content using inert, server-rendered content using vision, or a mixture of both.

Note: If you are building an application that only acts as a service or only exposes an API, you may not have a need for the inert and vision plugins, or a need to have folders for client and views.

Refactor the Hapi Project

Before continuing further, refactor your project with the following steps.

Create a folder under src named plugins. Create a new file under src/plugins named index.js. Add the following code to this file.

"use strict";

module.exports.register = async server => {
    // more to come later
};

Create a new folder under src named routes. Create a new file under src/routes named index.js. Add the following code to this file.

"use strict";

module.exports.register = async server => {
    server.route( {
        method: "GET",
        path: "/",
        handler: () => {
            // a handler can return text, HTML, JSON,
            // a file, or just about anything
            return "My first hapi server!";
        }
    } );
};

Create a new file under src named app.js. Add the following code to this file.

"use strict";

const Hapi = require( "hapi" );
const plugins = require( "./plugins" );
const routes = require( "./routes" );

module.exports.createServer = async config => {
    const server = Hapi.server( config );
    // register plugins
    await plugins.register( server );

    // register routes
    await routes.register( server );

    return server;
};

Last, modify src/index.js to match the following code.

"use strict";

const app = require( "./app" );

const port = 8080;
const config = { port };

const start = async () => {
    try {
        // create the server
        const server = await app.createServer( config );

        // start the server
        await server.start();

        console.log( `Server running at  http://localhost:${ port }` );
    } catch ( err ) {
        console.log( err );
        process.exit( 1 );
    }
}

start();

Extend Hapi with Plugins and Libraries

By design, the core hapi service focuses on basic server functionality. Plugins add additional features and capabilities to hapi. Your application may use a mix of official plugins, third-party plugins, and custom plugins you write. Here are just a sample of the more commonly used plugins.

Plugin Description
inert Use to serve static files and directories.
vision Render templates.
blipp Displays all the defined routes on startup.
hapi-pino Fast application logger that logs information in JSON format.
bell Third-party authentication.

Here are a few useful libraries commonly found in hapi projects.

Library Description
joi JSON object schema validation.
boom Use to generate and return HTTP error messages.
bounce Selectively catch and rethrow errors.
wreck Collection of HTTP client utilities.
lab Testing framework with code coverage analysis.
code Test assertion library to use with lab.

Install and Register hapi Plugins

In this next step, install two hapi plugins and configure them. From the command line, install blipp and hapi-pino.

npm install blipp hapi-pino

Next, modify src/plugins/index.js and replace the contents of this file with the following code.

"use strict";

const blipp = require( "blipp" );
const pino = require( "hapi-pino" );

const isDev = process.env.NODE_ENV !== "production";

module.exports.register = async server => {
    await server.register( [ blipp, {
        plugin: pino,
        options: {
            prettyPrint: isDev,
            logEvents: [ "response", "onPostStart" ]
        }
    } ] );
};

server.register() can take a single plugin or an array of plugins. A plugin can be registered using an instance of the plugin itself, such as the case with registering blipp. A plugin can also be registered using the plugin configuration object syntax, demonstrated by registering hapi-pino with its configuration options.

Create a Custom hapi Plugin

You can create your custom plugins for hapi to do all sorts of things, such as modify server configuration, add routes, or listen for server events. In this step, create a plugin that listens for when a server starts and logs a message.

Create a new file under src/plugins named serverStart.js. In this file, add the following code.

"use strict";

module.exports = {
    name: "serverStart",
    version: "1.0.0",
    register: async ( server, { message } ) => {
        server.events.on( "start", () => {
            const msg = message || `Server running at ${ server.info.uri }`;
            server.log( [ "info", "server" ], msg );
        } );
    }
};

A hapi plugin is a JavaScript object with a name property, a version property, and a register function with two arguments: server and options. Your serverStart plugin has an asynchronous register function that takes a server argument and uses object destructuring to take a message passed in the options. If the code does not specify a message, the plugin generates a default message.

To use this new plugin, modify src/plugins/index.js with the following code.

"use strict";

const Blipp = require( "blipp" );
const HapiPino = require( "hapi-pino" );
const serverStart = require( "./serverStart" );

const isDev = process.env.NODE_ENV !== "production";

module.exports.register = async server => {
    await server.register( [ Blipp, {
        plugin: HapiPino,
        options: {
            prettyPrint: isDev,
            logEvents: [ "response" ]
        }
    }, {
        plugin: serverStart,
        options: {
            message: `My hapi server is running at ${ server.info.uri }`
        }
    } ] );
};

Now run your application using:

node .

Your console output should look similar to the following.

method  path                          description
------  ----------------------------  -----------
GET     /

[1544478627595] INFO  (7408 on mycomputer):
    tags: [
      "info",
      "server"
    ]
    data: "My hapi server is running at http://mycomputer:8080"

Add Server Rendered Templates for a Better UI

So far the application has only returned plain text. A hapi application can respond to requests with text, static files, content dynamically generated from templates, or other types of media, such as JSON. In this step, add support for Embedded JavaScript templates, or EJS.

First, install the required dependencies using npm.

npm install vision ejs

Create a new file in the src/views folder named layout.ejs. Add the following HTML to this file.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title><%= title %></title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
</head>
<body>
    <%- content %>
</body>
</html>

Create a new file in the src/views folder named index.ejs. Add the following HTML to this file.

<div class="container">
    <h1 class="header"><%= title %></h1>
    <p><%= message %></p>
</div>

Next, update src/app.js to configure the vision plugin and ejs template engine.

"use strict";

const Hapi = require( "hapi" );
const vision = require( "vision" );
const ejs = require( "ejs" );
const plugins = require( "./plugins" );
const routes = require( "./routes" );

module.exports.createServer = async config => {
    const server = Hapi.server( config );

    // add the vision plugin and 
    // register EJS template view support
    await server.register( vision );
    server.views( {
        engines: { ejs },
        relativeTo: __dirname,
        path: "views",
        layout: true
    } );

    // register plugins
    await plugins.register( server );

    // register routes
    await routes.register( server );

    return server;
};

Now, update src/routes/index.js to return the rendered view instead of text.

"use strict";

module.exports.register = async server => {
    server.route( {
        method: "GET",
        path: "/",
        handler: async ( request, h ) => {
            try {
                const message = "My first hapi server!";
                return h.view( "index", {
                    title: "Home",
                    message
                } );
            } catch ( err ) {
                server.log( [ "error", "home" ], err );
            }
        }
    } );
};

Note: In the previous handler function, the request contains information about the incoming request, and h is the response toolkit that includes properties and utilities for creating responses.

Secure Your hapi Server with Bell and Okta

Unless you are building a static website, chances are your application needs a way to allow visitors to register for an account, log in, reset their password, and so forth. Add to the mix the ever-changing landscape of security concerns, and you can easily see implementing and maintaining a sound security strategy is far from a trivial task.

The good news is Okta's developer platform, built on the latest open security standards, makes this step very easy.

To begin, create a free developer account with Okta at developer.okta.com. Click the Create Free Account button, or click the Sign Up button.

After creating your account, click the Applications link at the top, and then click Add Application.

Next, choose a Web Application and click Next.

Enter a name for your application, such as My Hapi Server. Verify the port number is the same as configured for your local web application. Then, click Done to finish creating the application.

Enable self-service registration

One of the great features of Okta is allowing users of your application to sign up for an account. By default, this feature is disabled, but you can easily enable it. First, click on the Users menu and select Registration.

  1. Click on the Edit button.
  2. Change Self-service registration to Enabled.
  3. Click the Save button at the bottom of the form.

Manage Configuration with DotEnv

Node.js applications typically use environment variables for configuration. However, managing environment variables can be a chore. A popular module for managing application configuration data is dotenv.

Install dotenv as a project dependency.

npm install dotenv

Create a file named .env in the root folder of the project, and add the following configuration.

Note: When using a source control system such as git, do not add the .env file to source control. Each environment requires a custom .env file. It is recommended you document the values expected in the .env file in the project README or a separate .env.sample file.

# Server configuration
NODE_ENV=production
PORT=8080
HOST_URL=http://localhost:8080
COOKIE_ENCRYPT_PWD=superAwesomePasswordStringThatIsAtLeast32CharactersLong!

# Okta configuration
OKTA_ORG_URL=https://{yourOktaDomain}
OKTA_CLIENT_ID={yourClientId}
OKTA_CLIENT_SECRET={yourClientSecret}

Now, update src/index.js to use the dotenv module.

"use strict";

// Load in environment configuration
require( "dotenv" ).config();
const app = require( "./app" );

const DEFAULT_PORT = 8080;
const port = process.env.PORT || DEFAULT_PORT;
const config = { port };

const start = async () => {
    try {
        // create the server
        const server = await app.createServer( config );

        // start the server
        await server.start();
    } catch ( err ) {
        console.log( err );
        process.exit( 1 );
    }
};

start();

Go to your Okta account and click on the Dashboard link. On the right side of the page, you should find your Org URL. Copy and paste this value into your .env file to replace the value for OKTA_ORG_URL.

Click on the Applications link, and then click on the name of your new application. Click on the General tab, and find near the bottom of the page a section titled Client Credentials. Copy the Client ID and Client secret values and paste them into your .env file to replace {yourClientId} and {yourClientSecret}, respectively.

Create A Secure Web Application

First, install the bell and hapi-auth-cookie plugins. Bell is an authentication plugin, and hapi-auth-cookie is for cookie-based session management.

npm install bell hapi-auth-cookie

Under src/plugins create a new file named auth.js and add the following code.

"use strict";

const bell = require( "bell" );
const authCookie = require( "hapi-auth-cookie" );

const isSecure = process.env.NODE_ENV === "production";

module.exports.register = async server => {
    // register plugins
    await server.register( [ authCookie, bell ] );

    // configure cookie authorization strategy
    server.auth.strategy( "session", "cookie", {
        password: process.env.COOKIE_ENCRYPT_PWD,
        redirectTo: "/authorization-code/callback", // If there is no session, redirect here
        isSecure // Should be set to true (which is the default) in production
    } );

    // configure bell to use your Okta authorization server
    server.auth.strategy( "okta", "bell", {
        provider: "okta",
        config: { uri: process.env.OKTA_ORG_URL },
        password: process.env.COOKIE_ENCRYPT_PWD,
        isSecure,
        location: process.env.HOST_URL,
        clientId: process.env.OKTA_CLIENT_ID,
        clientSecret: process.env.OKTA_CLIENT_SECRET
    } );
};

Next, update src/plugins/index.js to register the new module.

"use strict";

const blipp = require( "blipp" );
const pino = require( "hapi-pino" );
const serverStart = require( "./serverStart" );
const auth = require( "./auth" );

const isDev = process.env.NODE_ENV !== "production";

module.exports.register = async server => {
    await server.register( [ blipp, {
        plugin: pino,
        options: {
            prettyPrint: isDev,
            logEvents: [ "response" ]
        }
    }, {
        plugin: serverStart,
        options: {
            message: `My hapi server is running at ${ server.info.uri }`
        }
    } ] );
    await auth.register( server );
};

Now, modify src/routes/index.js to the following code.

"use strict";

const boom = require( "boom" );

module.exports.register = async server => {
    server.route( {
        method: "GET",
        path: "/",
        config: {
            auth: {
                strategy: "session",
                mode: "optional"
            }
        },
        handler: async ( request, h ) => {
            try {
                const message = request.auth.isAuthenticated ? `Hello, ${ request.auth.credentials.profile.firstName }!` : "My first hapi server!";
                return h.view( "index", {
                    title: "Home",
                    message,
                    isAuthenticated: request.auth.isAuthenticated
                } );
            } catch ( err ) {
                server.log( [ "error", "home" ], err );
            }
        }
    } );

    server.route( {
        method: "GET",
        path: "/login",
        options: {
            auth: "session",
            handler: async request => {
                return `Hello, ${ request.auth.credentials.profile.email }!`;
            }
        }
    } );

    server.route( {
        method: "GET",
        path: "/authorization-code/callback",
        options: {
            auth: "okta",
            handler: ( request, h ) => {
                if ( !request.auth.isAuthenticated ) {
                    throw boom.unauthorized( `Authentication failed: ${ request.auth.error.message }` );
                }
                request.cookieAuth.set( request.auth.credentials );
                return h.redirect( "/" );
            }
        }
    } );

    server.route( {
        method: "GET",
        path: "/logout",
        options: {
            auth: {
                strategy: "session",
                mode: "try"
            },
            handler: ( request, h ) => {
                try {
                    if ( request.auth.isAuthenticated ) {
                        // clear the local session
                        request.cookieAuth.clear();
                    }

                    return h.redirect( "/" );
                } catch ( err ) {
                    request.log( [ "error", "logout" ], err );
                }
            }
        }
    } );
};

Create a new folder under src/views named partials. Create a new file in the partials folder named navigation.ejs. Add the following HTML to this file.

<nav>
    <div class="nav-wrapper">
    <% if ( isAuthenticated ) { %>
    <a href="/logout">Logout</a>
    <% } else { %> 
    <a href="/login">Login</a>
    <% } %>
</nav>

Update src/views/layout.ejs to include the navigation.ejs file when it renders.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title><%= title %></title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
</head>
<body>
    <% include partials/navigation %>
    <%- content %>
</body>
</html>

Now, you are ready to start your application again.

node .

In your browser, navigate to http://localhost:8080/. Click on the Login button at the top. You should see a prompt to log in to your Okta account.

Note: To verify authentication is working as expected, you may need to open a new browser or use a private/incognito browser window.

After logging in, you should be redirected back to the home page and see something like the following.

After authentication, the following profile information is available on every request as part of request.auth.

// request.auth (Example)
{
    "isAuthenticated": true,
    "credentials": {
        "provider": "okta",
        "token": "...",
        "expiresIn": 3600,
        "profile": {
            "id": "0012345",
            "username": "john.henry@company.com",
            "firstName": "John",
            "lastName": "Henry",
            "email": "john.henry@company.com",
            "raw": {
                "sub": "0012345",
                "name": "John Henry",
                "locale": "en-US",
                "email": "john.henry@company.com",
                "preferred_username": "john.henry@company.com",
                "given_name": "John",
                "family_name": "Henry",
                "zoneinfo": "America/Los_Angeles",
                "updated_at": 1544212558,
                "email_verified": true
            }
        }
    }
}

Add Tests to Your Hapi Project

A hapi project would not be complete without tests. Lab and code are the preferred test libraries for hapi projects. TestDouble is also a handy utility for replacing, mocking dependencies, and verifying behavior.

From the command line, install the following developer dependencies required for testing.

npm install --save-dev code lab testdouble

Create a new folder in the root of the project named test. Add a file to this folder named app.js. In this file, add the following code.

"use strict";

const td = require( "testdouble" );
td.replace( "hapi-pino" );
require( "dotenv" ).config();
const { expect } = require( "code" );
const Lab = require( "lab" );

const app = require( "../src/app" );

const lab = exports.lab = Lab.script();
const { describe, it } = lab;

describe( "App", () => {
    it( "home page returns valid response", async () => {
        const server = await app.createServer( { port: 12345 } );
        await server.initialize();
        const res = await server.inject( {
            url: "/",
            method: "GET"
        } );
        expect( res.statusCode ).to.equal( 200 );
        expect( res.result ).to.exist();
        expect( res.result ).to.contain( "My first hapi server!" );
    } );
} );

Modify your package.json file, and change the test scripts property to the following.

    "test": "lab -c"

Now, run the test from the command line using the following command.

npm run test

The output from this first test should look similar to the following.

  .

1 tests complete
Test duration: 67 ms
No global variable leaks detected
Coverage: 88.73% (16/142)
src/plugins/serverStart.js missing coverage on line(s): 8, 9
src/routes/index.js missing coverage on line(s): 17, 24, 35, 46-50, 64, 65, 69, 75, 77, 78

One of the great features of lab is the ability to analyze your tests for code coverage. The code analysis report includes lines of your source code currently missing test coverage.

Go Get Hapi!

In this tutorial, you have learned the basics of creating a modern web application with hapi and some of the tools that are part of the hapi ecosystem. Below are more resources to explore.

You can find the complete project source code on GitHub.

Thanks for reading, and happy... er... hapi coding!

Like this article? Follow @reverentgeek on Twitter

This content is sponsored via Syndicate Ads.