Borduhh
Borduhh

Reputation: 2185

Express/React with CORS - Setting HTTP-Only Secure Cookie for React SPA

I am trying to set up a simple API/SPA using Express as the API (api.mysite.co) and React as the SPA (app.mysite.co) on an AWS ElasticBeanstalk/S3 Deployment. For authentication, I am trying to set up a JWT inside of an HTTP-only secure cookie. But for some reason, the cookie never makes it to the client.

I have changed the domain to mysite.co for security purposes.

Here is my app.js file in express:

const dotenv = require('dotenv');
const express = require('express');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const helmet = require('helmet');
const passport = require('passport');
const cors = require('cors');

const usersRouter = require('./routes/users');

const env = process.env.NODE_ENV || 'development';
const port = process.env.PORT || '3000';
const app = express();

// Setup dev env variables and modules
if (env === 'development') {
  dotenv.config();
  app.use(logger('dev'));
}

app.use(helmet());

// Setup CORS requests based on environment
if (env === 'development') {
  app.use(cors());
} else {
  app.use(
    cors({
      origin: 'https://app.mysite.co',
      credentials: true,
    }),
  );
}

app.use(cookieParser());

// Handle CORS pre-flight reqests
app.options('*', cors());

app.use(passport.initialize());
require('./lib/auth/passport')(passport); // eslint-disable-line global-require

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use('/users', usersRouter);

// Catch all route that masks 404 for security
app.get('*', (req, res) => {
  res.status(401).json({ error: 'Unauthorized access' });
});

app.listen(port);

And my ./routes/users file with the login route. I have confirmed that it is working because the response is send and the user is logged in to the app. But for some reason the cookie is never set in chrome.

const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');

const router = express.Router();

const mongodb = require('../db/mongodb');
const validateRegisterInput = require('../lib/validation/register');
const validateLoginInput = require('../lib/validation/login');

// // POST /users/login - User Login
router.post('/login', (req, res) => {
  mongodb.initDb().then((db) => {
    const collection = db.collection('users');
    const { errors, isValid } = validateLoginInput(req.body);

    if (!isValid) {
      return res.status(400).json(errors);
    }

    const { email, password } = req.body;

    return collection
      .find({ email })
      .limit(1)
      .toArray((connectErr, users) => {
        if (connectErr) {
          errors.email = 'Could not verify email. Please try again later.';
          return res.status(500).json(errors);
        }

        // Pull user out of array
        const user = users[0];

        if (!user) {
          errors.email = 'Username and Password do not match.';
          return res.status(400).json(errors);
        }

        return bcrypt.compare(password, user.password).then((isMatch) => {
          if (isMatch) {
            const payload = {
              id: user._id, // eslint-disable-line no-underscore-dangle
              expire_date: Date.now() + parseInt(process.env.JWT_EXPIRATION_MS, 10),
            };

            return jwt.sign(JSON.stringify(payload), process.env.PASSPORT_SECRET, (err, token) => {
              if (err) {
                return res.status(500).json({
                  password: 'There was an internal error. Please try again later',
                });
              }

              res.cookie('jwt', token, {
                domain: 'app.mysite.co',
                httpOnly: true,
                secure: true,
                maxAge: parseInt(process.env.JWT_EXPIRATION_MS, 10),
              });
              return res.status(200).json({
                name: user.name,
              });
            });
          }

          errors.password = 'Username and Password do not match.';
          return res.status(401).json(errors);
        });
      });
  });
});

module.exports = router;

In the React SPA I am using Axios to make the POST call. In the app.js file I am setting:

axios.defaults.baseURL = 'https://api.mysite.co';
axios.defaults.withCredentials = true;

Then making the call like such:

/**
 * @description Logins in new user and sets their token in localStorage
 * @param {object} user Input from the login form.
 */
export const loginUser = user => dispatch => axios
  .post('/users/login', user)
  .then((res) => {
    dispatch(setCurrentUser(res.data));
  })
  .catch((err) => {
    dispatch(handleError(err));
  });

One thing to note is that when I am sending the request, both the pre-flight OPTIONS and POST calls have this for the request headers:

Provisional headers are shown
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: POST
Origin: https://app.mysite.co
Referer: https://app.mysite.co/login
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36

I have done some research and it seems that when the Provisional headers are shown warning is shown, it means the API call is not working correctly. But I am getting the follow response from the API on the POST call:

Request URL: https://api.mysite.co/users/login
Request Method: POST
Status Code: 200 
Remote Address: 52.201.140.183:443
Referrer Policy: no-referrer-when-downgrade

access-control-allow-credentials: true
access-control-allow-origin: https://app.mysite.co
content-length: 16
content-type: application/json; charset=utf-8
date: Sat, 23 Feb 2019 15:22:33 GMT
etag: W/"10-pyeidcXwb9ByfCNz+iqLTARQNP8"
server: nginx/1.14.1
status: 200
vary: Origin
x-powered-by: Express

Provisional headers are shown
Accept: application/json, text/plain, */*
Content-Type: application/json;charset=UTF-8
Origin: https://app.mysite.co
Referer: https://app.mysite.co/login
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36

Showing that it is a success and then the user is logged in.

Any help would be greatly appreciated since it seems I am way out of my depths here. I am not getting any server or client erros logged and from everything I have read, setting the credentials: true flag on both Express and Axios should have done the trick.

Thanks!

Upvotes: 2

Views: 5919

Answers (1)

Borduhh
Borduhh

Reputation: 2185

So after trying some different things, the issue was with setting the domain in res.cookie to the subdomain. I changed that code to:

              res.cookie('jwt', token, {
                domain: 'mysite.co',
                secure: true,
                httpOnly: true,
                maxAge: parseInt(process.env.JWT_EXPIRATION_MS, 10),
              });

and now the cookie is being set.

Upvotes: 3

Related Questions