dato search shopping envelope Pinterest youtube LinkedIn Facebook Twitter instagram search

hapi.js, cors, cookies, and axios

Much as we love working with hapi.js, sometimes things can be hard to get right the first time around.

We’ve been working on a web client written in React that is talking to an API written with hapi. Accessing the API requires authorization, so we needed to store a session token in the user’s browser. The easy solution would be to store the token in session- or localstorage, but this time we wanted the token in a HTTP-only cookie, and that turned out to be a bit more involved than we had anticipated.

Looking in the hapi documentation and elsewhere on the ‘net gave us bits and pieces of the solution, but we never found anything that covered exactly what we wanted to do. So in case you happen to be struggling with this also, here is a description of the steps we took to get it to work.

tl;dr

In order to successfully set cookies from a Hapi API running on api.example.com in a browser that is running a webapp loaded from client.example.com you will need to do the following:

  • Set the cookie domain to ‘example.com‘.
  • Ensure that CORS is enabled on the Hapi server.
  • You should probably restrict CORS access to a list of allowed origins – including client.example.com or *.example.com. However, setting cookies with unrestricted CORS origins (‘*’) will work with Hapi.
  • Your server and client will both need to opt in to set cookies in response to CORS requests. On the server you will need to add credentials: true to the CORS options. If the web app is using axios you can add { withCredentials: true } to the request options object.

Setting and reading a cookie in hapi

Here is a basic hapi.js server that sets a cookie when we post to the /login endpoint and echoes the session token back to us when we access the /customers endpoint:

'use strict';
const Hapi = require('@hapi/hapi');

const init = async () => {
  const server = Hapi.server({
    port: 18000,
    host: 'localhost'
  });

  server.state('access_token', {
    ttl: 7 * 24 * 60 * 60 * 1000, // 7 days
    isSecure: true,
    domain: 'h2g2.dk',
    path: '/',
    isHttpOnly: true
  });

  server.route({
    method: 'POST',
    path: '/login',
    handler: (request, h) => {
      h.state('access_token', 'a8dk4tssdk');
      return 'you are logged in';
    }
  });

  server.route({
    method: 'GET',
    path: '/customers',
    handler: (request, h) => {
      return 'access_token from cookie is' + request.state.access_token;
    }
  });

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

process.on('unhandledRejection', err => {
  console.log(err);
  process.exit(1);
});

init();

Our server is running behind an nginx proxy, on https://api.h2g2.dk. When we POST to the /login endpoint using Postman we can see that the cookie is being returned from the server, and if we then GET the /customers endpoint, we see that the access_token cookie is being sent along with our request and read by the server. So far, so good.

Enter CORS

Next, we tried to do the same in a react app that we loaded from https://client.h2g2.dk. This is what a POST to our /login endpoint looks like using axios:

axios.post('https://api.h2g2.dk/login', { });

Unfortunately, this doesn’t work. We get a CORS error in the browser console:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.h2g2.dk/login. (Reason: CORS header 'Access-Control-Allow-Origin' missing).

The web client is running on a different (sub)domain from the api, and cross-origin http requests between the two won’t work unless we explicitly allow it on the server. A simple way of doing that is by telling hapi to allow CORS requests from any origin on all routes. We do this by amending our server definition:

const server = Hapi.server({
    port: 18000,
    host: 'localhost',
    routes: {
        cors: {
            origin: ['*']
        }
    }
});

Now we can POST to the /login endpoint from our web app without any errors. Everything looks good – except, on closer inspection, it turns out that the cookie is not being set in our browser. But the response from the server does includes an appropriate set-cookie header:

set-cookie: access_token=a8dk4tssdk; Max-Age=604800; Expires=Thu, 16 May 2019 18:35:54 GMT; Secure; HttpOnly; SameSite=Strict; Domain=h2g2.dk; Path=/

So why isn’t our cookie being set?

OPTIONS and credentials

Looking closer at the browsers’ Network tab we notice that the first time we POST to our /login endpoint, the browser actually sends two requests. The second request is our POST, but the first is an OPTIONS request:

Request URL:https://api.h2g2.dk/login
Request method:OPTIONS
...

This is a preflight request that the browser will issue under some circumstances, before sending the CORS request, in order to confirm that the operation we are attempting is allowed. The full response to our OPTIONS request is:

HTTP/2.0 200 OK
 date: Thu, 09 May 2019 18:34:18 GMT
 content-length: 0
 set-cookie: __cfduid=db04e3f34429e9b3c91b2825cba96e48f1557426858; expires=Fri, 08-May-20 18:34:18 GMT; path=/; domain=.h2g2.dk; HttpOnly
 access-control-allow-origin: https://client.h2g2.dk
 access-control-allow-methods: POST
 access-control-allow-headers: Accept,Authorization,Content-Type,If-None-Match
 access-control-max-age: 86400
 access-control-expose-headers: WWW-Authenticate,Server-Authorization
 cache-control: no-cache
 expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
 server: cloudflare
 cf-ray: 4d45c44959a6b79f-CDG
 X-Firefox-Spdy: h2

This doesn’t mean much if you don’t know what to look for, but as it turns out there is something missing: there is no Access-Control-Allow-Credentials header in the response. That header is required for cookies to be set on CORS requests, and both the client and the server need to indicate that they agree to use credentials. On the client we can accomplish this by changing our axios POST code to:

axios.post('https://api.h2g2.dk/login',{},{ withCredentials: true });

In the API server we change our server definition to:

const server = Hapi.server({
    port: 18000,
    host: 'localhost',
    routes: {
        cors: {
            origin: ['*'],
            credentials: true
        }
    }
});

That ought to do it. But in reality it … it does work. Didn’t expect that – please stand by while I look at my notes again.

Restricted CORS origins

Back again, sorry about that.

According to my notes, after repeatedly failing to get this to work, we decided to impement the same functionality in Express.js using the cors middleware. Hapi will often do extra stuff for you without bothering you with the details, while Express requires you to be much more explicit in your code. Usually that just means more work, but when troubleshooting something Express does tend to give you much more insight into exactly what is going on.

Using Express to do more or less the same as we have been doing with Hapi this far, we got this error in the browser console when trying to POST with CORS and credentials enabled:

... The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

We are not supposed to be able to do CORS requests with wildcard origins enabled, so why doesn’t the request to the Hapi server fail for the same reason?

Looking at the response headers we get from Express when we try to POST we see this:

access-control-allow-origin: *

But using Hapi we get:

access-control-allow-origin: https://client.h2g2.dk

So when the Hapi server is configured to allow any origin, it ensures that CORS requests with credentials will work anyway, by replacing the wildcard in the access-control-allow-origin header with the URL of the requesting client.

There are probably good reasons for that approach, but in our case we don’t really want the wildcard there, since the API is private (we just used the wildcard because we are lazy, and to simplify things while testing). All clients that are allowed access to our API will be on subdomains of h2g2.dk, so even though we strictly speaking don’t have to do this, we’ll restrict the allowed origins in our CORS configuration:

const server = Hapi.server({
    port: 18000,
    host: 'localhost',
    routes: {
        cors: {
            origin: ['*.h2g2.dk'],
            credentials: true
        }
    }
});

And that’s it. We can now set cookies on cross domain requests, and you should be able to do the same.