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)
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,