Search code examples
node.jsmongodbexpresssessionheroku

Session not persisting after redirect during Google OAuth 2.0 flow


I have been working on this bug for over 20 hours now, I can break it down in detail but can't solve it, so any help would be greatly appreciated.

Currently Req.user and req.session are not persisting after success redirect during Google OAuth, but they do persist using the other passport for local email/password login. Google OAuth also works fine during development using localhost and docker. It is a Mongo/Express/React/Node application where the frontend is hosted on Netlify and the backend is on Heroku. The database is on MongoDB Atlas.

Production repo: https://github.com/normnXT/note-keeper/tree/production

Have a look at the production repo if possible, but here it is part by part. The login function in the client:

const onGoogleLogin = () => {
        try {
            window.open(`${process.env.REACT_APP_SERVER_URL}/api/auth/google`, "_self");
        } catch (err) {
            toast.error(err.response.data);
        }
    };

The google oauth routes:

router.get("/google", passport.authenticate("google", ["profile", "email"]));

router.get(
    "/google/callback",
    passport.authenticate("google", { failureRedirect: '/login/failed', prompt: 'consent',  accessType: 'offline' }),
    (req, res) => {
        console.log("Session after Google auth:", req.session);
        console.log("User after Google auth:", req.user);
        res.redirect(process.env.CLIENT_URL);
    },
);

The server.js file:

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

const localRouter = require("./routes/local");
const noteRouter = require("./routes/notes");
const authRouter = require("./routes/auth");
const User = require("./models/User");

const MongoStore = require("connect-mongo");
const mongoose = require("mongoose");
const cors = require("cors");
const passport = require("passport");
const bcrypt = require("bcryptjs");
const LocalStrategy = require("passport-local").Strategy;
const OAuthStrategy = require("passport-google-oauth20").Strategy;

const app = express();
app.use(express.json());

// Enables cross-origin resource sharing between API's and client
app.use(
    cors({
        origin: process.env.CLIENT_URL,
        methods: "GET,POST,PUT,DELETE",
        credentials: true,
    }),
);

// Sets up express session to store user data server side and on MongoDB Atlas
app.set("trust proxy", 1);
app.use(
    session({
        secret: process.env.SESSION_SECRET,
        resave: false, // Session will only be resaved if it is modified
        saveUninitialized: false, // Sessions will only be saved once initialized
        proxy: true,
        secure: true,
        cookie: {
            secure: true,
            maxAge: 1000 * 60 * 60 * 24,
            sameSite: "none",
            httpOnly: true,
        },
        store: MongoStore.create({ mongoUrl: process.env.MONGO_URI }),
    }),
);

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

// Google passport strategy for OAuth 2.0 login
// https://developers.google.com/identity/protocols/oauth2
passport.use(
    new OAuthStrategy(
        {
            clientID: process.env.GOOGLE_CLIENT_ID,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET,
            callbackURL: `${process.env.SERVER_URL}/api/auth/google/callback`,
            proxy: true,
            scope: ["profile", "email"],
        },
        async (accessToken, refreshToken, profile, done) => {
            console.log({ accessToken, refreshToken, ...profile});
            try {
                let user = await User.findOne({
                    email: profile.emails[0].value,
                });
                console.log("User returned by profile email:", user)
                if (user) {
                    user.googleId = profile.id;
                    user.displayName = profile.displayName;
                    user.image = profile.photos[0].value;

                    await user.save();
                } else {
                    const newUser = new User({
                        googleId: profile.id,
                        displayName: profile.displayName,
                        email: profile.emails[0].value,
                        image: profile.photos[0].value,
                    });
                    console.log("User before save():", user)
                    user = await newUser.save();
                }
                console.log("User before done():", user)
                return done(null, user);
            } catch (err) {
                return done(err, null);
            }
        },
    ),
);

// Passport strategy for email/password login combination
// User submitted passwords are compared with stored hashes
passport.use(
    new LocalStrategy(
        { usernameField: "email", passwordField: "password" },
        async (email, password, done) => {
            try {
                const user = await User.findOne({ email: email });

                if (!user) {
                    return done(null, false);
                } else {
                    bcrypt.compare(password, user.password, (err, res) => {
                        if (err) throw err;

                        if (res === true) {
                            return done(null, user);
                        } else {
                            return done(null, false);
                        }
                    });
                }
            } catch (err) {
                console.log(err);
                done(err);
            }
        },
    ),
);

// On user login, stores user ID in the session store
passport.serializeUser((user, done) => {
    console.log("serializing");
    console.log(user.id);
    process.nextTick(function () {
        done(null, user.id);
    });
});

// On each subsequent API request, deserializer uses the stored user ID to retrieve user data and stores it under req.user
// Only users with a Google profile have a user.image, so it is optional
passport.deserializeUser((id, done) => {
    console.log("deserializing");
    process.nextTick(function () {
        User.findOne({ _id: id })
            .then((user) => {
                if (!user) {
                    return done(null, false);
                }

                const userInfo = {
                    id: user._id,
                    displayName: user.displayName,
                    email: user.email,
                    image: user.image || null,
                };

                done(null, userInfo);
            })
            .catch((err) => {
                console.log(err);
                done(err);
            });
    });
});

// Route handling to follow /notes, /auth, and /local subdirectories
app.use("/api/notes", noteRouter);
app.use("/api/auth", authRouter);
app.use("/api/local", localRouter);

// Database connection
mongoose
    .connect(process.env.MONGO_URI)
    .then(() => console.log(`Mongodb Connected`))
    .catch((error) => console.log(error));

const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
});

When the user is redirected by the callback function's successRedirect, meaning the user was authenticated properly, they reach the home page and this function triggers to get the users information:

const getGoogleProfile = useCallback(async () => {
        try {
            const res = await axios.get('/api/auth/login/success', {
                withCredentials: true,
            });
            context.setUserData(res.data.user);
        } catch (err) {
            console.log(err);
        }
    }, []);

    useEffect(() => {
        getGoogleProfile();
    }, [getGoogleProfile]);

That functions route that is triggered fails to access req.user, as req.user is "undefined", and the deserializer never runs:

router.get("/login/success", (req, res) => {
    console.log(req.session)
    console.log(req.user)
    if (req.user) {
        res.status(200).json({
            user: req.user,
        });
    } else {
        res.status(403).send("Not authenticated");
    }
});

During the authentication process, the users info is successfully stored in the User database and the session is successfully stored in the mongostore, and the serializer runs. During the google callback, req.user and req.session both are logged to the console successfully and the backend can access them.

At some point during the successRedirect in the google callback route, req.session and req.user are lost and are no longer accessible even though they are stored in the mongoStore. No error messages occur during the auth process.

I have quadruple checked all my .env variables set in Heroku and Netlify, along with all settings in the Google API console. I tried manually saving the session with req.session.save() and a callback function in the google callback. I have also tried a variety of different configs for CORs, session, passport strategy and the google routes.

I am close to just attempting to host on a different platform than Netlify/Heroku, or going with something other than express sessions, because I feel like im out of options!

Any help would be appreciated.


Solution

  • Solved it. The session cookie with credentials was being successfully sent to the browser in response to the Google OAuth 2.0 callback, but with .heroku.app as the domain. When the get request to the client on Netlify was made for the redirect, the cookie was cleared and a new cookie was sent in its place due to the domain mismatch. In development, both the front and backend were using localhost as the domain, so there were no conflicts.

    Changing the domain in the session configs to the intended recipient, the client, ".netlify.app", caused an error to raise in the cookie, "Set-Cookie was blocked because its Domain attribute was invalid with regards to the current host URL". I believe the cookie was failing to be set due to Netlify and Heroku being on the public suffix registry, but I am not sure.

    I don't think it's possible to use some OAuth services with a backend and frontend on two separate hosting platforms with different domains. The solution was to register a custom domain name and set it up in both Netlify and Heroku. Netlify is handling the nameservers and backend requests are being sent to Heroku through the "api" subdomain.