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.
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"]
}));
secure: true/false
and sameSite: "Strict","None"
to all their combinations but no change.127.0.0.1
instead of localhost
anyway but there was no change.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.
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;
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;
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;
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");
});
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.
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):
res.cookie("token", JSON.stringify(token), {
secure: true,
httpOnly: true,
withCredentials: true,
maxAge: 8 * 60 * 60 * 1000, // 8 hours
sameSite: "None",
path: "/",
});