Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

connect.sid is not being created, req.session doesn't seem to be retrieving the token when frontend and backend are on separate ports #20

Open
Coinhexa opened this issue Nov 23, 2024 · 2 comments

Comments

@Coinhexa
Copy link

Coinhexa commented Nov 23, 2024

What is expected?

  • I hit the GET /csrf/token endpoint from my frontend
  • A new csrf token is generated as no cookie is set yet
  • This csrf token is saved to redis store via connect-redis and connect.sid with sessionId is stored as a cookie on the browser
  • I hit POST /login endpoint with username and password that requires CSRF token to be present and this works

What is happening?

  • The csrf token is saved to redis store but I don't see a connect.sid cookie on my Firefox/Chrome/Safari browser
  • req.session.csrfToken returns undefined even though the value is literally saved inside redis

Backend

  • My express backend
    app.js
require("dotenv-flow").config();
const cors = require("cors");
const http = require("http");
const express = require("express");
const passport = require("passport");
const RedisStore = require("connect-redis").default;
const Redis = require("ioredis");
const expressSession = require("express-session");
const { Strategy: LocalStrategy } = require("passport-local");
const { Server } = require("ws");
const helmet = require("helmet");
const { csrfSync } = require("csrf-sync");

const { generateToken, csrfSynchronisedProtection } = csrfSync();

const client = new Redis({
  host: process.env.REDIS_SESSION_HOST,
  port: process.env.REDIS_SESSION_PORT,
  password: process.env.REDIS_SESSION_PASSWORD,
  db: process.env.REDIS_SESSION_DB,
});

const store = new RedisStore({ client });

const loggedInUser = {
  userId: 1,
  userName: process.env.TEST_USER_EMAIL,
  isAdmin: false,
};

const sessionParser = expressSession({
  secret: process.env.SESSION_SECRET,
  resave: process.env.SESSION_RESAVE === "true",
  rolling: process.env.SESSION_ROLLING === "true",
  saveUninitialized: process.env.SESSION_SAVE_UNINITIALIZED === "true",
  cookie: {
    httpOnly: process.env.SESSION_HTTP_ONLY === "true",
    // Doesnt work if maxAge is not of type Number
    maxAge: +process.env.SESSION_MAX_AGE,
    // https://stackoverflow.com/questions/61999068/how-do-i-use-cookies-in-express-session-connect-sid-will-soon-be-rejected
    // https://github.com/jaredhanson/passport-twitter/issues/101
    sameSite: process.env.SESSION_SAME_SITE === "true",
    secure: process.env.SESSION_SECURE === "true",
  },
  store,
});

const app = new express();

app.use(
  cors({
    origin: "http://localhost:3000",
    credentials: true,
  })
);

// https://github.com/SoftwareBrothers/adminjs/issues/607#issuecomment-693621747
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        frameAncestors: ["'self'", "https://www.recaptcha.net"],
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
        styleSrc: ["'self'", "https:", "'unsafe-inline'"],
        baseUri: ["'self'"],
        fontSrc: ["'self'", "https:", "data:"],
        imgSrc: [
          "'self'",
          "data:",
          "https://imagine.ai",
          "https://www.imagine.ai",
        ],
      },
    },
    referrerPolicy: {
      policy: "same-origin",
    },
  })
);

passport.serializeUser((user, done) => {
  done(null, user.userId);
});
passport.deserializeUser(async (userId, done) => {
  done(null, loggedInUser);
});

passport.use(
  "local",
  new LocalStrategy(
    {
      usernameField: "email",
      passwordField: "password",
      badRequestMessage: "email or password is missing",
    },
    async (email, password, done) => {
      if (
        email === process.env.TEST_USER_EMAIL &&
        password === process.env.TEST_USER_PASSWORD
      ) {
        return done(null, loggedInUser);
      } else {
        return done(null, false);
      }
    }
  )
);

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(sessionParser);
app.use(passport.initialize());
app.use(passport.session());

app.get("/user", (req, res) => {
  return res.json(req.user);
});

app.get("/csrf/token", (req, res) => {
  req.session.test = 'abracadabra';
  return res.json({ token: generateToken(req) });
});

app.get("/session/token", (req, res) => {
  return res.json({ token: req.session.csrfToken, test: req.session.test });
});

app.post("/login", csrfSynchronisedProtection, (req, res, next) => {
  passport.authenticate("local", {}, async (error, user, info) => {
    if (error) {
      return next(error);
    }
    if (!user) {
      return res.json(false);
    }
    req.logIn(user, (error) => {
      if (error) {
        return next(error);
      }
      return res.json(user);
    });
  })(req, res, next);
});

app.post("/logout", (req, res, next) => {
  req.logout();
  req.session.destroy((err) => {
    if (err) {
      return next(err);
    }
    req.user = null;
    res.clearCookie("connect.sid");
    return res.json(true);
  });
});

const map = new Map();

const server = http.createServer(app);

const websocketServer = new Server({ noServer: true });

server.on("upgrade", (request, socket, head) => {
  sessionParser(request, {}, () => {
    console.log(
      request.session,
      request.user,
      request.session.user,
      request.headers.cookie
    );

    websocketServer.handleUpgrade(request, socket, head, function (ws) {
      websocketServer.emit("connection", ws, request);
    });
  });
});

websocketServer.on("connection", function (ws, request) {
  const user = request.session.user;

  map.set(user, ws);

  ws.on("message", function (message) {
    //
    // Here we can now use session parameters.
    //
    console.log(`Received message ${message} from user ${user}`);
  });

  ws.on("close", function () {
    map.delete(user);
  });
});

server.listen(+process.env.PORT, () =>
  console.log(`server listening on ${process.env.PORT}`)
);

  • My .env.development file
PORT='8000'
REDIS_SESSION_DB='5'
REDIS_SESSION_HOST='localhost'
REDIS_SESSION_PASSWORD='somepassword'
REDIS_SESSION_PORT='6379'
SESSION_HTTP_ONLY='true'
SESSION_MAX_AGE='86400000'
SESSION_NAME='ch_test'
SESSION_RESAVE='false'
SESSION_ROLLING='false'
SESSION_SAME_SITE='true'
SESSION_SAVE_UNINITIALIZED='false'
SESSION_SECRET='abracadabrafoobarbaz'
SESSION_SECURE='false'
TEST_USER_EMAIL='[email protected]'
TEST_USER_PASSWORD='123456789'

Frontend

  • It is a Nuxt 2 project and I ll include only the relevant files here
  • The store file below executes on the server side first inside nuxtServerInit and sends a GET /csrf/token
    store/index.js
export const state = () => ({
  csrfToken: null,
  redirect: null,
})

export const mutations = {
  SET_CSRF_TOKEN(state, csrfToken) {
    state.csrfToken = csrfToken
  },
  SET_REDIRECT(state, redirect) {
    state.redirect = redirect
  },
}

export const actions = {
  async getCsrfToken({ commit }) {
    try {
      const { data } = await this.$axios.get('/csrf/token')
      commit('SET_CSRF_TOKEN', data.token)
      console.log('SAVE CSRF TOKEN', data.token)
    } catch (error) {
      console.error(error)
    }
  },
  async nuxtServerInit({ commit, dispatch }, { $dayjs, req }) {
    await dispatch('getCsrfToken')
  },
}
  • I wrote an axios plugin that uses @nuxtjs/axios to get this csrf token from the vuex store (nuxtServerInit stores it first) and then send it as header
    plugins/axios.js
export default ({ $axios, store }) => {
  // Inject $hello(msg) in Vue, context and store.
  $axios.defaults.timeout = 30000

  // https://github.com/axios/axios/issues/1543
  $axios.defaults.transitional.clarifyTimeoutError = true

  $axios.onRequest((config) => {
    const csrfToken = store.state.csrfToken
    console.log(config.url, 'ON REQUEST', csrfToken)

    if (
      csrfToken &&
      ['get', 'post', 'put', 'delete', 'patch'].includes(config.method)
    ) {
      config.headers['X-CSRF-Token'] = csrfToken
    }
    return config
  })
}

Libraries

  • express-session
  • connect-redis
  • ioredis
  • cors
  • csrf-sync
  • helmet

Download link

Questions

  • I am overworked and probably stressed out badly and I have somehow messed this up
  • Kindly help me understand why connect.sid cookie is not stored on the browser side
@Coinhexa Coinhexa changed the title Doesn't seem to work when frontend and backend are running on separate ports connect.sid is not being created, req.session doesn't seem to be retrieving the token when frontend and backend are on separate ports Nov 23, 2024
@psibean
Copy link
Contributor

psibean commented Nov 24, 2024

The detail in this post is immaculate - that is super appreciated. Note that, the session cookie not being set is an issue with your express-session and cors configuration, and isn't specific to csrf-sync. The reason your csrfToken isn't persisted, is because your browser isn't storing the session, so when another request is made, it has no session to check. Each request it's attempting to create a new session because one doesn't appear to exist, but then the browser isn't receiving the cookie (due to configuration issues).

First of all, when I run npm run dev to run the frontend, it seems to run on a random port every time, this isn't great, because you'll need to set the frontend domain + port number in the origin of the cors configuration, e.g. http://localhost::2140

Secondly, for your local development, you cannot set sameSite on the cookie to true (this is the same as setting it to 'strict').

Usually you want to set the origin for cors based on an environment variable.
You have a lot of things that don't need to be environment variables.

For example, httpOnly for session should ALWAYS be true.
Your resave and saveUninitialised should also be true typically, and even if you don't want them to be true, they don't need to be environment specific/dependent.

I was able to get the connect.sid cookie to be set when clicking the Get Session Token button by fixing the above 2 points, the sameSite cookie option on the session, and the cors origin setting needs to match the frontend. There do seem to be some other issues present, but one at a time.

I'd recommend combining the frontend and backend into a single repo, a monorepo, this is particularly great if you make use of workspaces, as installed dependencies can be shared across projects and save space.

Also node_modules should not be committed to the repo, this can complicate things.

If you wanted a more realistic dev environment, you could setup a docker environment where you use a single domain and reverse proxy to the backend and frontend via an nginx config, this would allow you to use one domain/origin/site in your local setup.

@Coinhexa
Copy link
Author

Coinhexa commented Nov 24, 2024

  • First of all thank you very much for your kind words and taking the time to reply to this issue
  • I have managed to fix this issue
  • I apologize that this has nothing to do with your library
  • It also has nothing to do with express-session settings such as saveUninitialized, resave, sameSite, secure etc (Ok maybe secure 😅 ) but definitely not the rest
  • I tried all possible combinations of these values across Safari, Chome and Firefox and nothing worked
  • The real issue is that Nuxt Server and Express Server are NOT the same
  • Something I didn't even consider
  • nuxtServerInit from my frontend basically runs inside the so called Nuxt Server where it gets a response with set-cookie from the Express server but the browser ignores it
  • Here is the modified backend that works
  • Here is the modified frontend
  • All the information on what, why, where, how is added to the README on both repos
  • Feel free to close this issue

EDIT 1

  • Must have been sleeping when node_modules got added to git 2 yrs ago when this repository was made. I removed it recursively from every commit
  • Prepare to be blown away looking at how many CORS origins I added 🤣

Questions

  • I do have 2 questions if you don't mind answering
    • How do I handle this whole CSRF thingy while running tests
    • Is my implementation for login and logout correct? I haven't changed the CSRF tokens at all after the user logged in or logged out. Or does it change in some way when sessions are involved?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants