node.jspassport.jsexpress-sessionpassport-google-oauth

Circular redirect issue with Google OAuth2 and PassportJS in a NodeJS project


In my NodeJS project, I'm trying to protect access to certain controllers (since all the routes under the controller would also be protected). When using my front-end, I want to ensure users are from MYDOMAIN.com by having them authenticate using their Google Workspace account. However, I also want to support stateless API calls using access tokens (e.g. using Postman). I've managed to get my BearerStrategy working with PassportJS. But, when a user attempts to access my protected URL through the web, I end up in a circular loop where the user keeps getting redirected back to "/" after completing the authentication. I THINK (though this is my first real NodeJS project - the one I'm learning all these new building blocks with) it's because my express-session handling isn't being done right and it's deleting my sessions which I was trying to use to keep my redirectTo URL.

I tried come ChatGPT help - that got my BearerStrategy working. But since I also want to controller to support the GoogleStrategy with Session support, it seems to delete the session when it goes there.

My app.js (simplified to just a non-protected, protected, and my auth routes):

//app.js
const express = require("express");
const app = express();
const passport = require("passport");
const expressUtils = require("./expressUtils");

// Used to parse JSON bodies
expressUtils.setMiddlewares(app);
// Add View Template Engine - EJS
expressUtils.setViewEngine(app);
// Set the public_static folder for CSS, Images, etc.
expressUtils.setPublicStaticFolder(app);
expressUtils.setPassportStrategy(); // Set up the Passport Strategy
expressUtils.setPassportMiddlewares(app); // Add Passport middlewares

//I have more controllers
const checkinController = require("./controllers/checkin");
const locationController = require("./controllers/location");
const authController = require("./controllers/auth");

// app.use(controllers);
app.use("/auth", authController);
app.use("/", checkinController);

// Add middleware for protected controllers/routes
app.use(
  "/locations",
  function (req, res, next) {
    // Save the URL of the resource the user is trying to access
    req.session.redirectTo = req.originalUrl;
    console.log(req.session);
    next();
  },
  function (req, res, next) {
    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith("Bearer ")
    ) {
      // If the request has an Authorization header that starts with 'Bearer ',
      // use the Bearer strategy with session: false
      console.log("app.js Locations: bearer");
      console.log(req.headers);
      passport.authenticate("bearer", { session: false })(req, res, next);
    } else {
      //next();
      console.log("app.js Locations: google");
      // Otherwise, use the Google strategy with session: true
      passport.authenticate("google", { session: true })(req, res, next);
    }
  },
  expressUtils.ensureAuthenticated,
  expressUtils.refreshTokenIfNeeded,
  locationController
);

// Sync the session store
expressUtils.sessionStore.sync();

const port = process.env.PORT || 3000;
app.listen(port, function () {
  console.log("Server is running on port " + port);
});

I keep my middleware in a separate module (expressUtils.js):

const express = require("express");
// Use flash messages
const flash = require("connect-flash");
const session = require("express-session");
const methodOverride = require("method-override");
const axios = require("axios");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const BearerStrategy = require("passport-http-bearer").Strategy;
const { OAuth2Client } = require("google-auth-library"); // for refresh token
const config = require("./config"); // Make sure your config file includes Google clientID and clientSecret
const client = new OAuth2Client(config.secret_clientid);
const util = require("util"); // The util.inspect function is used to print out the entire object, even if it has nested properties.

// For Sessions - Sequelize connection to db and session store
const db = require("./models/index.js");
const sequelize = db.sequelize;
const SequelizeStore = require("connect-session-sequelize")(session.Store);

// Initialize session store
const sessionStore = new SequelizeStore({
  db: sequelize,
});

console.log("ClientID:", config.secret_clientid);
console.log("ClientSecret:", config.secret_oauth);

function setViewEngine(app) {
  app.set("view engine", "ejs");
}

function setPublicStaticFolder(app) {
  app.use(express.static("public"));
}

function setPassportStrategy() {
  console.log

("Setting up passport strategy...");

  passport.use(
    new GoogleStrategy(
      {
        clientID: config.secret_clientid,
        clientSecret: config.secret_oauth,
        callbackURL: "/auth/google/callback",
        accessType: "offline", // Request offline access to obtain refresh token
        scope: [
          "https://www.googleapis.com/auth/userinfo.profile",
          "https://www.googleapis.com/auth/userinfo.email",
        ], // Add the scope parameter here
      },
      function (accessToken, refreshToken, profile, cb) {
        console.log("GoogleStrategy callback invoked...");
        console.log("Access Token: ", accessToken);
        console.log("Refresh Token: ", refreshToken);
        console.log("Profile: ", util.inspect(profile, { depth: null }));

        if (profile._json.hd === "MYDOMAIN.com") {
          // Store the access token and refresh token in the user object
          profile.accessToken = accessToken;
          profile.refreshToken = refreshToken;
          return cb(null, profile);
        } else {
          return cb(null, false, { message: "Invalid domain" });
        }
      }
    )
  );

  passport.use(
    new BearerStrategy(async function (token, done) {
      console.log("BearerStrategy callback invoked...");
      console.log("Token: ", token);

      try {
        // Try to verify the token as an ID token
        const ticket = await client.verifyIdToken({
          idToken: token,
          audience: config.secret_clientid,
        });
        const payload = ticket.getPayload();
        console.log("Payload: ", util.inspect(payload, { depth: null }));

        // Check the 'hd' field in the payload to make sure it's your domain
        if (payload.hd !== "MYDOMAIN.com") {
          throw new Error("Invalid domain");
        }

        // If everything checks out, the token is valid
        done(null, payload, { scope: "read" });
      } catch (error) {
        console.log("Error verifying ID token: ", error.message);

        // If the token couldn't be verified as an ID token, try to verify it as an access token
        try {
          const payload = await verifyAccessToken(token);
          done(null, payload, { scope: "read" });
        } catch (error) {
          console.log("Error verifying access token: ", error.message);
          done(null, false, { message: error.message });
        }
      }
    })
  );

  passport.serializeUser(function (user, cb) {
    console.log("Serializing user...");
    console.log("User: ", util.inspect(user, { depth: null }));
    cb(null, user);
  });

  passport.deserializeUser(function (obj, cb) {
    console.log("Deserializing user...");
    console.log("Object: ", util.inspect(obj, { depth: null }));
    cb(null, obj);
  });
}

function setPassportMiddlewares(app) {
  app.use(passport.initialize());
  app.use(passport.session());
}

function setMiddlewares(app) {
  app.use(express.urlencoded({ extended: true }));
  app.use(express.json());

  //Allow the app to override form methods since HTML forms don't support DELETE natively
  app.use(methodOverride("_method"));

  app.use(
    session({
      secret: config.secret_session, // TO DO: Change this
      resave: false,
      saveUninitialized: true,
      store: sessionStore, // Use the new session store
    })
  );

  app.use(flash());

  // this ensures flash messages are available to all routes and views
  app.use((req, res, next) => {
    res.locals.messages = req.flash();
    next();
  });

  app.use((req, res,

 next) => {
    console.log("------------------------");
    console.log("Request Details:");
    console.log("Method:", req.method);
    console.log("URL:", req.originalUrl);
    console.log("Body:", req.body);
    console.log("Session:", req.session);
    next();
  });
}

async function verifyAccessToken(token) {
  const response = await axios.get(
    `https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${token}`
  );
  const payload = response.data;

  // Check the 'aud' field in the payload to make sure it's your app's client ID
  if (payload.aud !== config.secret_clientid) {
    throw new Error("Invalid client ID");
  }

  // Check the 'hd' field in the payload to make sure it's your domain
  const emailDomain = payload.email.split("@")[1];
  if (emailDomain !== "MYDOMAIN.com") {
    throw new Error("Invalid domain");
  }

  // If everything checks out, the token is valid
  return payload;
}

// Support both access tokens (BearerStrategy) and Google
function ensureAuthenticated(req, res, next) {
  console.log("ensureAuthenticated: headers...");
  console.log(req.headers);
  console.log("ensureAuthenticated: user...");
  console.log(req.user);
  if (req.user) {
    return next();
  }
  // Store original URL before redirecting to login
  req.session.redirectTo = req.originalUrl;
  console.log("ensureAuthenticated: redirectTo...");
  console.log(req.session.redirectTo);
  req.session.save((err) => {
    if (err) {
      return next(err);
    }
    res.redirect("/auth/google");
  });
}

// Custom middleware to check if access token is expired and refresh it if necessary
function refreshTokenIfNeeded(req, res, next) {
  const user = req.user;
  console.log("refreshTokenIfNeeded: req.user");
  console.log(req.user);

  if (!user || !user.accessToken || !user.refreshToken) {
    console.log("refreshTokenIfNeeded: user, accessToken or refreshToken missing");
    // If user or tokens are missing, proceed without refreshing
    return next();
  }

  const accessTokenExpiration = user.accessTokenExpiration;

  // Check if access token is expired or about to expire in a certain threshold
  if (
    accessTokenExpiration &&
    Date.now() >= accessTokenExpiration - 60000 // Refresh token if access token is about to expire in 1 minute
  ) {
    const refreshToken = user.refreshToken;

    // Use the refresh token to get a new access token
    oauth2Client
      .refreshToken(refreshToken)
      .then((refreshResponse) => {
        const newAccessToken = refreshResponse.credentials.access_token;

        // Update the user object with the new access token and its expiration
        user.accessToken = newAccessToken;
        user.accessTokenExpiration =
          Date.now() + refreshResponse.credentials.expires_in * 1000;

        console.log("refreshTokenIfNeeded: access token refreshed");

        // Proceed to the next middleware or route handler
        next();
      })
      .catch((error) => {
        // Handle the error, e.g., log or respond with an error message
        console.error("Error refreshing access token:", error);
        next(); // Proceed to the next middleware or route handler even if the refresh fails
      });
  } else {
    // Access token is still valid, proceed to the next middleware or route handler
    console.log("refreshTokenIfNeeded: access token still valid");
    next();
  }
}

module.exports = {
  setViewEngine,
  setPublicStaticFolder,
  setMiddlewares,
  setPassportStrategy,
  setPassportMiddlewares,
  ensureAuthenticated,
  addAuthorizationHeader,
  refreshTokenIfNeeded,
  sessionStore,
};

And I maintain my Google Callback URL etc. in my auth.js controller:

// In app.js, I use this as "/auth"
const express = require("express");
const passport = require("passport");
const router = express.Router();

router.get("/google", function (req, res, next) {
  console.log("/auth/google");
  // Save the redirectTo value in the session
  req.session.redirectTo = req.query.redirectTo;
  passport.authenticate("google", {
    scope: ["profile", "email"],
    hd: "MYDOMAIN.com", // Specify the hosted domain (your Google Workspace domain)
  })(req, res, next);
});

router.get(
  "/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => {
    // Redirect to original URL or homepage if no URL is stored
    var redirectTo = req.session.redirectTo || "/";
    delete req.session.redirectTo; // I've tried with this commented out too
    res.redirect(redirectTo);
  }
);

module.exports = router;

In case it's helpful, here's a snippet from the logs when I run with DEBUG=express-session:

Request Details:
Method: GET
URL: /auth/google/callback?code=CODE&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&authuser=0&hd=MYDOMAIN.com&prompt=consent
Body: {}
Session: Session {
  cookie: { path: '/', _expires: null, originalMaxAge: null, httpOnly: true },
  flash: {},
  redirectTo: '/locations'
}
GoogleStrategy callback invoked...
Access Token:  ACCESS_TOKEN
Refresh Token:  undefined
Profile:  {
  id: 'USER_ID',
  displayName: 'USER_NAME',
  name: { familyName: 'FAMILY_NAME', givenName: 'GIVEN_NAME' },
  emails: [ { value: 'USER_EMAIL', verified: true } ],
  photos: [
    {
      value: 'USER_PHOTO_URL'
    }
  ],
  provider: 'google',
  _json: {
    sub: 'USER_ID',
    name: 'USER_NAME',
    given_name: 'GIVEN_NAME',
    family_name: 'FAMILY_NAME',
    picture: 'USER_PHOTO_URL',
    email: 'USER_EMAIL',
    email_verified: true,
    locale: 'en',
    hd: 'MYDOMAIN.com'
  }
}
Executing (default): SELECT "sid", "expires", "data", "createdAt", "updatedAt" FROM "Sessions" AS "Session" WHERE "Session"."sid" = 'SESSION_ID';
Executing (default): DELETE FROM "Sessions" WHERE "sid" = 'SESSION_ID'
Serializing user...
User:  {
  id: 'USER_ID',
  displayName: 'USER_NAME',
  name: { familyName: 'FAMILY_NAME', givenName: 'GIVEN_NAME' },
  emails: [ { value: 'USER_EMAIL', verified: true } ],
  photos: [
    {
      value: 'USER_PHOTO_URL'
    }
  ],
  provider: 'google',
  accessToken: 'ACCESS_TOKEN',
  refreshToken: undefined
}
Executing (default): SELECT "sid", "expires", "data", "createdAt", "updatedAt" FROM "Sessions" AS "Session" WHERE "Session"."sid" = 'NEW_SESSION_ID';
Executing (default): INSERT INTO "Sessions" ("sid","expires","data","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5)

Solution

  • I learned that the PassportJS passport-google-oauth20 strategy will delete the session on authentication. So, in order to retain the redirect URL, you can't place it on the session. As an alternative, I used cookie-parser to set a secure cookie with the redirect URL and the deleted the cookie after redirect.

    After making that change, I was also able to remove these 2 middlewares:

    expressUtils.ensureAuthenticated,
    expressUtils.refreshTokenIfNeeded,