node.jsexpresssessionsession-cookiesexpress-session

Value saved to Express session is not present on later requests


My app is acting as an OAuth server for a 3rd party service which will be accessed through an in our app. The authentication process all happens behind the scenes without an intermediate screen for a user to click a log in button (since the process is initiated from within our app already). The challenge is that since all the OAuth requests are initiated from the 3rd party within the iframe (and therefore we can't interact with or intercept them), the only way we can identify the user who should be authenticated is through a session cookie.

So on registration or login to our app, we set req.session.userId equal to the id of the current logged-in user. The expectation would be that when the iframe initiates the requests to our OAuth endpoints, beginning with GET /oauth/code that we could read the value of req.session.userId on the incoming request. However, this did not seem to work, no matter what set of configurations I made on the Express session. By inspecting the request in more detail, I did notice the session with the userId value was present within req.sessionStore, so I set up my authCode handler to loop over these sessions and find the one with userId set.

This appeared to be working, until we noticed in a live environment that in certain specific instances, a user would log into our app and see a completely different user's account within their iframe. (One example is User A authenticates within our app and the 3rd party, then User B opens an incognito window and navigates directly to the 3rd party site without being logged in on our app). In this instance, User A's session appears to be present in the sessionStore on the request from User B. This is obviously not OK, so it appears to be back to the drawing board and trying to determine why we are not getting the userId value back from req.session on incoming requests.

I'll post relevant code here, but let me know if there's anything else that would help to understand the problem better.

/api/app.js

const session = require('express-session');

// Express Session Middleware
app.set('trust proxy', 1); // this is for ngrok in our local instnace
app.use(session({
  secret: 'starboard penguin disco',
  resave: true,
  name: 'test',
  proxy: true,
  saveUninitialized: true,
  cookie: {
    httpOnly: true,
    sameSite: 'none', 
    secure: false, // this is false now for local testing but true in production
  },
}));

/api/auth.js

exports.login = async (req, res, next) => {
  try {
    const user = await db.User.findOne({
      email: req.body.email,
    });
    const { id, fullName, email } = user;

    const isMatch = await user.comparePassword(req.body.password, next);

    if (isMatch) {
      ({ accessToken, refreshToken } = generateTokens(user));

      // Save user id to sesson cookie
      req.session.userId = id;

      return res.status(201).json({
        id,
        email,
        fullName,
        jwt: accessToken,
        refresh_token: refreshToken,
      });
    }

    return next({
      status: 400,
      message: 'Invalid email or password',
    });
  } catch (err) {
    return next({
      status: 400,
      message: 'Invalid email or password',
    });
  }
};

/api/handlers/oauth.js

// This handles GET calls to /oauth/code and is the first of 3 requests made by 3rd party
exports.getAuthCode = async (req, res) => {
  try {
    // Extract user ID from request
    let userId = '';
    console.log(req.session.userId) // returns undefined
    const sessionStore = promisify(req.sessionStore.all.bind(req.sessionStore));
    const sessions = await sessionStore();
    Object.values(sessions).forEach((session) => {
      if (session.userId) {
        userId = session.userId;
      }
    });
    console.log('getting user id from session');
    console.log(userId);

    // Generate a secure random authorization code
    const codeValue = crypto.randomBytes(16).toString('hex');
    // Save the code along with the client ID, redirect URI, and user ID
    const authCode = await AuthorizationCode.create({
      code: codeValue,
      clientId: req.query.client_id, // Assuming this is passed by the client
      redirectUri: req.query.redirect_uri, // Assuming this is passed by the client
      expiresAt: new Date(Date.now() + 10 * 60 * 1000), // Code expires in 10 minutes
      user: { id: userId },
    });

    await authCode.save();
    return res.redirect(`${req.query.redirect_uri}?code=${authCode.code}`);
  } catch (err) {
    console.error(err);
    return res.status(err.code || 500).json(err);
  }
};

Solution

  • A flow diagram of your architecture would really help to understand your problem. The following is a flow diagram of what i presume your architecture is doing, but i could have off course misunderstood it. Also, since you talk about Iframes I assume that your app is running inside a browser, where cross-origin cookies behave as expected. Some info about that would also help.

    enter image description here

    With that said, are you sure the request made from inside the iframe includes the session cookie set outside of the iframe? That would be the most obvious reason for why you cannot access req.session.userId: You simply are not sending the cookie with the request, therefore you are not accessing any session data.

    The request from inside the Iframe is a cross-site request, it will be sent only if the cookie has both sameSite = 'none' and secure=true

    in your code, in /api/app.js i noticed this comment:

      ...
      cookie: {
        httpOnly: true,
        sameSite: 'none', 
        secure: false, // this is false now for local testing but true in production
      },
    

    If you were testing it with secure: false it would never have worked, You can read more about this behaviour here

    Now, about looping the sesion data: The correct way to read and write session data is by accessing the properites directly:

    req.session.someData = "test" //write some session data
    console.log(req.session.userId) //access some other session data
    

    If you are not reading the data, the issue is somewhere else in the session, for example you could not be sending the cookie as I hypothesized.

    No matter what the issue is, this code is doing something very dangerous, and it's absolutely not what you want:

    Object.values(sessions).forEach((session) => {
      if (session.userId) {
        userId = session.userId;
      }
    });
    

    This is basically the same thing as logging the user into a random account they don't own if they don't provide a valid session.