Add Social Authentication to Remix Run Project | Express Server

Wednesday, February 9, 2022

Social Login is a form of single sign-on using existing information from a social network provider like Google, Facebook, Twitter, Github, and so on.

If your app server is Remix Server then you should check out this blog-post where we added Social Auth when our server is Remix Server. In this blog post, we would talk about how we can integrate Login via Google with Remix Run project when our server is Express Server!

Setting up an app in Google Developer Console and getting GOOGLE_CLIENT_ID and GOOLGE_CLIENT_SECRET

Before we start any development, first we need to set up an app in Google Developer Console and get the necessary credentials.

  1. Go to https://console.cloud.google.com/
  2. Select a project or create a new one
  3. Once the project is selected, go to https://console.cloud.google.com/apis/credentials
  4. Open the Credentials tab from the sidebar
  5. Click on Create Credentials and select OAuth client ID
  6. Select Application type as Web application.
  7. Name your application. For e.g. LoginApp
  8. In the Authorised redirect URIs section, click on ADD URI and add http://localhost:3000/auth/google/callback
  9. Click on CREATE
  10. You will now get your GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. Copy and save them securely.

Create a Remix Run Project

We are going to use passport.js to provide authentication functionality. It is a middleware for Node.js and works well with Express-based web applications. So, we are going to create a fresh Remix project and choose Express as our server.

npx [email protected]
# choose Express Server
cd [whatever you named the project]

Load Secrets as ENVIRONMENT VARIABLES

  1. Let us first create a .env file to store the GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET downloaded earlier.
touch .env
  1. Install the dotenv package to load these variables into our app.
npm i dotenv
  1. Update our Server File
// file: server/index.js

// this loads the `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` as environment variables
// that can be accessed via
// process.env.GOOGLE_CLIENT_ID
// process.env.GOOGLE_CLIENT_SECRET
require("dotenv").config();

Install Dependencies

Let us install the required packages from npm.

  1. PassportJS
  2. passport-google-oauth2 - This is the Google Strategy Library that we are going to use to provide Login with Google functionality.
npm i passport passport-google-oauth2

Initializing Passport

// file: server/index.js;

const passport = require("passport");

app.use(passport.initialize());

Configuring Strategy

// file: server/index.js
...
const GoogleStrategy = require("passport-google-oauth2").Strategy;

async function handleStrategyCallback(request, accessToken, refreshToken, profile, done) {
  // Use profile data and token to create a user profile in your system
  // profile object will have data like
  // id, emails, displayName, photos, provider, profileUrl

  // Something like
  const user = await User.findOrCreate({ googleId: profile.id });

  // once user is stored in your db
  return done(null, user);
}

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: "http://localhost:3000/auth/google/callback", // change the domain to your domain in production
      scope: ["openid email profile"],
      passReqToCallback: true,
    },
    handleStrategyCallback
  )
);
...

Implementing serializeUser() and deserializeUser()

Passport uses the serializeUser function to persist user data (after successful authentication) and function deserializeUser is used to retrieve the user data.

// file: server/index.js;

// user object is the authenticated user that we get from `handleStrategyCallback` function
passport.serializeUser((user, done) => {
  done(null, user);
});

// user object is the authenticated user that we get from `serializeUser` function
passport.deserializeUser((user, done) => {
  done(null, user);
});

Setting up request URLs

First, we are going to handle the route when the request initially comes to our backend i.e. when the user clicks on the Login with Google button then call a is made to our back-end. It would be the starting point for the social auth flow.

// file: server/index.js

app.use("/auth/google/", (req, res, next) => {
  // invoke passport authentication function with google strategy
  return passport.authenticate("google", {
    failureRedirect: "/error",
  })(req, res, next);
});

Now, the user would be redirected to the Google Sign-in screen where they can log in to their account and allow our app. Once that is done then our handleStrategyCallback function would be invoked with all the necessary information provided by Google and we save the user to our database. A call is made to the Authorised URI we provided during our initial project setup.

app.use("/auth/google/callback", (req, res, next) => {
  // callback from Google
  return passport.authenticate("google", {
    failureRedirect: "/error",
  })(req, res, next);
});

Handling User Session

  1. Since, we are using an Express server, we can manage the user session on our own using packages like express session. If we go that route then we have to make some changes to let passport manage the session.
// file: server/index.js

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

app.use(
  session({
    secret: "secret",
    resave: false,
    saveUninitialized: true,
  })
);

app.use(passport.session());

Now, we will have req.session.passort.user that will contain our authenticated user object. We can use this information to perform check on routes access.

  1. We can use Remix's built-in session management utilities to manage the session.

Remix Server Side Session Handling

Let us create session handling using Remix built-in utilities.

cd app
touch session.server.js
// file: app/session.server.js

import { createCookieSessionStorage, redirect } from "remix";

// loads this from .env file in production
const sessionSecret = "secret";

const storage = createCookieSessionStorage({
  cookie: {
    name: "cookie_name",
    secure: true,
    secrets: [sessionSecret],
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
  },
});

export async function createUserSession(userId, redirectTo) {
  const session = await storage.getSession();
  session.set("userId", userId);

  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await storage.commitSession(session),
    },
  });
}

In the above code snippet, we created a function createUserSession that takes userId as a parameter and creates the session for us. However, we don't have userId. We need to pass that userId from our express route handling to Remix securely.

We can do so by creating an API route in Remix server code and passing the userId to it as a cookie from our /auth/google/callback request. To securely pass it, we will use a package string-crypto to encrypt and decrypt the cookie value.

npm i string-crypto
const StringCrypto = require("string-crypto");

const { encryptString } = new StringCrypto();

function handleSocialLoginCallback(req, res, next) {
  // change the secret to something more secure
  // store it as env variable
  const encryptedUserId = encryptString(req.user.userId, "secret");

  res.cookie("cookie-name", encryptedUserId, {
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
  });

  return res.redirect(`http://localhost:3000/api/users/auth`);
}

app.use(
  "/auth/google/callback",
  (req, res, next) => {
    // callback from Google
    return passport.authenticate("google", {
      failureRedirect: "/error",
    })(req, res, next);
  },
  handleSocialLoginCallback
);

Create Remix Route and session creation

cd app/routes
touch api.users.auth.js
// file: api.users.auth.js
import { redirect } from "remix";
import StringCrypto from "string-crypto";

import { createUserSession } from "../app/session.server";

const { decryptString } = new StringCrypto();

export const loader = async ({ request }) => {
  const cookies = request.headers.get("Cookie") || "";
  const cookiesArray = cookies.split(";");
  const userCookie = cookiesArray.find((cookie) =>
    cookie.includes("cookie-name")
  );

  if (!userCookie) {
    return redirect("/error");
  }

  let encryptedUserId = userCookie.split("=")[1];
  encryptedUserId = decodeURIComponent(userId);

  const userId = decryptString(encryptedUserId, "secret");

  return await createUserSession(userId, "/dashboard");
};

In the above code snippet, we read the cookie from the request headers. Decoded it using the secret, created the user session, and redirected the logged-in user to the dashboard.

Handling Logout

Let us add a function to logout users that delete the session using the storage object we created earlier.

// file: app/session.server
...

export async function logout(request) {
  const session = await storage.getSession(request.headers.get("Cookie"));

  return redirect("/login", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}

Now, when we receive a request to GET /logout then we can call the above-created function and delete the user session.

See the entire flow live here -- https://devtools.tech/login

I hope this blog post helped you in some way. Please do share it and show our content much needed love! :D

If you feel something is missing/wrong/improvement is possible then feel free to reach out -- Devtools Tech and Yomesh Gupta