Search code examples
expresscookiesaxioscorsejs

Cookie sent from node server on localhost to ejs client but not sent back on subsequent requests from axios in browser. Working in Postman


Problem

I have two express node apps - one acting purely as a REST server and the other is the front end using ejs and sending requests to the back-end using axios. I'm trying to get this working in my dev environment where both apps are running on localhost.

I'm trying to log in at http://localhost:3000/login which sends the username/password as a POST request to http://localhost:4000/login and the Set-Cookie header is being set correctly for the token:

'set-cookie': 'token=[redacted]; Max-Age=28800; Domain=localhost; Path=/; Expires=Wed, 05 Apr 2023 20:42:47 GMT; HttpOnly; SameSite=Strict',

But then when redirected to the http://localhost:3000/transactions page after success, the cookie is not being sent so the auth fails.

What I've tried

This code is working for cookies using Postman which led me to think it's an issue with Chrome's security changes and/or CORS issue though I'm not getting any CORS error messages in the chrome console.

Other stackoverflow questions seem to confirm this theory but I still haven't been able to successfully send the cookie with subsequent requests to the server (i.e. on the /transactions call below)

My server side CORS config is this now, I think I've covered all the bases in the above question but I still suspect here's where my problem is:

app.use(cors({
    origin: ['http://127.0.0.1:3000','http://localhost:3000'],
    methods: ['POST', 'PUT', 'GET', 'OPTIONS', 'HEAD'],
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
    exposedHeaders: ['*', 'Authorization', "X-Set-Cookie"]
}));
  • I know I need to send the request with credentials, I've tried it both in each request and as a default Just in case it's an axios issue.
  • I know browsers have special handling of localhost where it's treated as a secure environment in some scenarios but I'm not clear if this is the case for setting secure cookies - I've switched the values of secure: true/false and sameSite: "Strict","None" to all their combinations but no change.
  • I tried switching everything (server and client) over to use 127.0.0.1 instead of localhost anyway but there was no change.
  • I tried installing a local SSL certificate using mkcert to make sure I could set secure: true and sameSite: "None" properly but no change. Note I removed this code before posting below as it seems like other people have got this working without needing HTTPS on localhost so I abandoned this and haven't included it in the below code.

Any ideas on what to try next would be greatly appreciated.

Extended code

Server - http://localhost:4000/ index.js
require('dotenv').config({path: './.env'});
const express = require('express');
const logger = require('morgan');
const expressSanitizer = require('express-sanitizer');
const cors = require('cors');
const cookieParser = require('cookie-parser');

let app = express();

app.use(cors({
    origin: ['http://127.0.0.1:3000','http://localhost:3000'],
    methods: ['POST', 'PUT', 'GET', 'OPTIONS', 'HEAD'],
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
    exposedHeaders: ['*', 'Authorization', "X-Set-Cookie"]
}));

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(expressSanitizer());
app.use(cookieParser());
app.use(logger('dev'));
app.use(require('./routes/general/authentication.js'));
app.use(require('./handlers/authHandler.js'));
app.use(require('./routes/general/generalRoute.js'));

// Start the server
if(!module.parent){
    let port = process.env.PORT != null ? process.env.PORT : 4000;
    var server = app.listen(port, 'localhost', function() {
        console.log(`Server started on port ${port}...`);
    });
}

module.exports = app;
authentication.js

process.env.SECURE_COOKIE = false

const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const db = require('../../database/database');
const { check, validationResult } = require('express-validator');

require('dotenv').config({path: './.env'});

const secret = process.env['SECRET'];
const dbURL = process.env['DB_URL'];
const saltRounds = 10;

router.post('/login', [ 
  check('username').exists().escape().isEmail(),
  check('password').exists().escape()
  ], async(req, res, next) => {
    try {

        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            res.statusCode = 400;
            return next('Authentication failed! Please check the request');
        }

        res.setHeader('content-type', 'application/json');

        if (req.body.username && req.body.password) {
            let dbhash = await db.getHashedPassword(req.body.username);
            bcrypt.compare(req.body.password, dbhash, async function(err, result) {
                if (err) {
                    res.statusCode = 400;
                    res.error = err;
                    return next('Authentication failed! Please check the request');
                }
                if (result) {
                    let userData = await db.getUserAuthData(req.body.username);
                    if (userData.app_access) {
                        let token = jwt.sign(
                            { user_id: userData.id },
                            secret,
                            { expiresIn: '24h' }
                        );
                        res.cookie("token", JSON.stringify(token), {
                            secure: process.env.SECURE_COOKIE === "true",
                            httpOnly: true,
                            withCredentials: true,
                            maxAge: 8 * 60 * 60 * 1000,  // 8 hours
                            sameSite: "Strict",
                            domain: "localhost"
                        });
                        res.statusCode = 200;
                        res.json({
                            success: true,
                            response: 'Authentication successful!'
                        });
                    } else {
                        res.statusCode = 401;
                        return next('User is not authorised');
                    }
                } else {
                    res.statusCode = 401;
                    return next('Incorrect username or password');
                }
            });
        } else {
            res.statusCode = 400;
            return next('Authentication failed! Please check the request');
        }
    } catch (err) {
        return next(err);
    }
});
  
module.exports = router;
authHandler.js
var jwt = require('jsonwebtoken');
require('dotenv').config({path: './.env'});
const secret = process.env['SECRET'];

var checkToken = function(req, res, next) {
    let token = req.cookies.token;
    console.log(req.headers);
    if (token) {
        // Remove Bearer from string
        if (token.startsWith('Bearer ')) {
            token = token.slice(7, token.length);
        } 
        // Remove quotes around token
        else if (token.startsWith('"')) {
            token = token.substring(1, token.length-1);
        }
        jwt.verify(token, secret, (err, decoded) => {
            if (err) {
                res.statusCode = 401;
                return next('Authentication failed! Credentials are invalid');
            } else {
                req.decoded = decoded;
                next();
            }
        });
    } else {
        res.statusCode = 400;
        return next('Authentication failed! Please check the request');
    }
};

module.exports = checkToken;
Client - http://localhost:3000/
const express = require('express');
const app = express();
const axios = require('axios');
axios.defaults.baseURL = "http://localhost:4000";
axios.defaults.withCredentials = true;

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static('resources'));

app.set('view engine', 'ejs');
app.set('views', 'views');

app.get('/transactions', async (req, res) => {
      axios.get('/budget/transactions', {
        withCredentials: true,
        headers: {
            'Content-Type': 'application/json',
        }
    })
        .then(response => {
            console.log(response.request);
            res.render('transactions', { name : 'transactions', title : 'transactions', ...response.data});
        })
        .catch(err => {
            console.log(err);
            res.render('transactions', { name : 'transactions', title : 'transactions', ...status });
        });
});

app.post('/login', async (req, res) => {
    axios.post('/login', 
    {
      username: req.body.email,
      password: req.body.password
    },
    {
      withCredentials: true,
      headers: {
          'content-type': 'application/json',
      }
    })
  .then(response => {
    console.log(response.headers);
    if (response.data.success === true) {
      res.redirect('/transactions');
    } else {
      res.redirect('/login');
    }
  });
});

app.listen(3000, () => {
    console.log("LISTENING ON PORT 3000");
});

Solution

  • I've fixed the problem. I was barking up the wrong tree with CORS - all the other answers I found turned it into a bit of a red herring.

    My problem was similar to this one that even though my "client" app was accessed through a browser, because I was using ejs it essentially meant that it's two servers talking to each other:

    Browser -> EJS server -> REST endpoint server.

    This meant that the browser had no knowledge of the cookies that were being sent back by the set-cookie header, and cookies are only saved by default when the client receiving the set-cookie header is a browser - axios does not save them by default when running inside an express server so I had to add manual handling of saving and sending the cookie on to the browser. See full client code below.

    Client - http://localhost:3000/
    const express = require('express');
    const app = express();
    const config = require('./config.json');
    const axios = require('axios');
    const cookieParser = require('cookie');
    const params404 = { name : '404', title : "404 Not Found" };
    axios.defaults.baseURL = "http://localhost:4000";
    axios.defaults.withCredentials = true;
    
    app.use(express.urlencoded({ extended: true }));
    app.use(express.json());
    app.use(express.static('resources'));
    
    app.set('view engine', 'ejs');
    app.set('views', 'views');
    
    app.get('/transactions', async (req, res) => {
        let cookie = req.headers.cookie;
        let headers = {
            'Content-Type': 'application/json',
        };
        if (cookie) {
            headers.Cookie = cookie;
        }
        axios.get('/budget/transactions', {
            withCredentials: true,
            headers: headers
        })
        .then(response => {
            res.render('transactions', { name : 'transactions', title : 'transactions', ...response.data});
        })
        .catch(err => {
            if (err?.response?.data != null) {
                console.error(err.response.data);
            } else {
                console.error(err);
            }
            let status = { success: err.status!=null?err.status:false }
            res.render('transactions', { name : 'transactions', title : 'transactions', ...status });
        });
    });
    
    app.post('/login', async (req, res) => {
        axios.post('/login', 
            {
            username: req.body.email,
            password: req.body.password
            },
            {
                withCredentials: true,
                headers: {
                'content-type': 'application/json',
            }
        })
        .then(response => {
        if (
            response.data.success === true && 
            response?.headers["set-cookie"] != null && 
            response.headers["set-cookie"][0] != null
        ) {
            let token = cookieParser.parse(response?.headers["set-cookie"][0]).token;
                res.cookie("token", token, {
                    secure: true,
                    httpOnly: true,
                    withCredentials: true,
                    maxAge: 8 * 60 * 60 * 1000,  // 8 hours
                    sameSite: "None"
                });
            res.redirect('/transactions');
        } else {
            res.redirect('/login');
        }
        });
    });
    
    app.listen(3000, () => {
        console.log("LISTENING ON PORT 3000");
    });
    

    I kept the CORS settings in place despite it not being relevant to the actual answer here's the final config I settled on that works with the above on localhost (for completeness):

    authentication.js
    res.cookie("token", JSON.stringify(token), {
                                secure: true,
                                httpOnly: true,
                                withCredentials: true,
                                maxAge: 8 * 60 * 60 * 1000,  // 8 hours
                                sameSite: "None",
                                path: "/",
                            });