node.jsexpresspassport.jsexpress-session

How can I retain Express Session + Passport Session in Node.js?


I am trying to integrate Steam accounts into my project, by linking them through passport-steam module.

My app is also using express-session, in order to authenticate signed in users.

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

const Redis = require("ioredis");
const RedisStore = require("connect-redis")(session);
const redisClient = new Redis({
    "host": "…",
    "port": 6379,
    "password": "…"
});

For example, upon signing in:

app.get("/sign-in/", function(req, res, next) {
    req.session._id = req.session._id;
    req.session.token = req.session.token;
        
    res.sendStatus(200);
});

These accounts are intended to be linkable to Steam accounts.

const passport = require("passport");
const SteamStrategy = require("passport-steam").Strategy;

passport.use(new SteamStrategy({
    "returnURL": "https://www.example.com/link/steam/redirect/",
    "realm": "https://www.example.com/",
    "apiKey": "…"
}, function(identifier, profile, done) {
    profile.identifier = identifier;
    return done(null, profile);
}));

passport.serializeUser(function(user, done) {
    done(null, user);
});
passport.deserializeUser(function(user, done) {
    done(null, user);
});

app.use(session({
    "resave": false,
    "saveUninitialized": false,
    "secret": "…",
    "store": new RedisStore({ "client": redisClient }),
    "cookie": {
        "secure": true,
        "sameSite": "none"
    }
}));

app.use(passport.initialize());
app.use(passport.session());

When a user signs in successfully in Steam, the Express Session disappears completely. I tried to resave the Express Session like so, but I still couldn't retain it:

app.get("/link/steam/", function(req, res, next) {
    req.session._id = req.session._id;
    req.session.token = req.session.token;
    req.session.urlExtension = language.urlExtension;
    
    req.session.save(function() {
        passport.authenticate("steam")(req, res, next);
    });
});
app.get("/link/steam/redirect/", passport.authenticate("steam", { "failureRedirect": "/" }), async function(req, res) {
    console.log(req.session._id); // undefined
    console.log(req.session.token); // undefined
    console.log(req.user.steamId); // The Steam ID is obtained
});

Solution

  • This was a very stubborn problem, but I managed to find a workaround.

    First, before we redirect the user to Steam, we can generate a state – a secret, unique one-time ID (random 16-byte hex string) to tie the session to the Steam flow.

    const crypto = require("crypto");
    
    app.get("/link/steam/", async function(req, res, next) {
        const state = crypto.randomBytes(16).toString("hex");
    
        await redisClient.set(`steam_state:${state}`, JSON.stringify({
            "_id": req.session._id,
            "token": req.session.token
        }), "EX", 300); // <- If the user doesn’t complete Steam login in 5 minutes, this state is discarded.
    
        res.cookie("steam_state", state, {
            "httpOnly": true,
            "secure": true,
            "sameSite": "none"
        });
    
        passport.authenticate("steam")(req, res, next);
    });
    

    In the Steam callback handler, Passport validates the Steam login response as normal. Afterwards, we log the user in: req.logIn(user, async function(err) {});

    Inside the function, we get the state from the secure cookie: const state = req.cookies?.steam_state;

    Finally, we restore the session: const data = await redisClient.get(`steam_state:${state}`);

    Just in case, we save the updated session: await new Promise(resolve => req.session.save(resolve));

    app.get("/link/steam/redirect/", function(req, res, next) {
        passport.authenticate("steam", async function(err, user) {
            if (err || !user) {
                return res.redirect("/");
            }
    
            req.logIn(user, async function(err) {
                if (err) {
                    return next(err);
                }
    
                const steamId = req.user.id; // Obtaining the Steam ID as expected
    
                const state = req.cookies?.steam_state;
    
                if (state) {
                    try {
                        const data = await redisClient.get(`steam_state:${state}`);
    
                        if (data) {
                            const parsedData = JSON.parse(data);
    
                            req.session._id = parsedData._id;
                            req.session.token = parsedData.token;
    
                            // Clearing the cookie
                            await redisClient.del(`steam_state:${state}`);
                            res.clearCookie("steam_state", {
                                "httpOnly": true,
                                "sameSite": "none",
                                "secure": isHTTPS
                            });
    
                            await new Promise(resolve => req.session.save(resolve));
                        }
                    }
                    catch (err) {
                        console.error(err);
                    }
                }
    
                // Outputs the Express Session
                console.log(req.session._id);
                console.log(req.session.token);
    
                // Outputs the Steam ID as well
                console.log(steamId);
    
                // …
            });
        })(req, res, next);
    });
    

    Middlewares must include cookie-parser as well:

    app.use(session({
        "resave": false,
        "saveUninitialized": false,
        "secret": "…",
        "store": new RedisStore({ "client": redisClient }),
        "cookie": {
            "secure": "true",
            "sameSite": "none"
        }
    }));
    
    const cookieParser = require("cookie-parser");
    app.use(cookieParser()); // <-
    
    app.use(passport.initialize());
    app.use(passport.session());