How to implement Google login in the MERN-based applications?

How to implement Google login in the MERN-based applications?

In today's fast-paced world, users demand ease of access. They don't want to spend time remembering the credentials they use in different applications. That's where Google Sign-In comes in, offering a seamless login experience that benefits both the users and developers.

This blog post dives into integrating Google Sign-In with your MERN stack application (MongoDB, Express.js, React, and Node.js), making login a breeze for your users and saving you valuable development time.

Prerequisites

Before we begin, ensure that you have an account on Google Cloud Console and a Project with Google Sign-in API enabled.

Go to API & Services > Credentials then click on Create credentials, select OAuth Client ID

  • Give a name for your web client (anything)

  • Put your server-hosted URL in the Authorized JavaScript origins (ex. http://localhost:3000)

  • Put your callback URL which will be redirected to when the server login is successful (ex. http://localhost:3000/auth/google/callback)

  • From the credentials page, you have to grab 3 things,

    1. Client secret

    2. Client ID

    3. Callback URL

Go to OAuth Consent Screen then provide these details

For the test users don't add any users, go to the next step and then the dashboard. Our Google Cloud console configuration is now complete.

Now its time to build our app, the technologies we're going to use are React(vite), MongoDB, Nodejs, and Express.js, Passport(for google login).

Backend Setup

Create a Nodejs application and install these packages,

mkdir server
cd server
npm init -y
npm i bcryptjs cors express mongoose jsonwebtoken morgan passport passport-google-oauth20 dotenv

The usage of the above packages are explained below

1. bcryptjs:

  • Purpose: Stores passwords securely in the database. It utilizes a one-way hashing function, making it impossible to retrieve the original password from the stored hash.

2. cors:

  • Purpose: Enables requests from a different domain (front-end) to access the backend API. This is crucial when the front-end and back-end are hosted on separate domains.

3. express:

  • Purpose: Provides a robust and flexible framework for building web applications and APIs. It simplifies building server-side logic, handling requests and responses, and routing.

4. mongoose:

  • Purpose: Provides a layer of abstraction over MongoDB, allowing developers to interact with the database using JavaScript objects and schemas. It simplifies data modeling and manipulation.

5. jsonwebtoken (jsonwebtoken):

  • Purpose: Enables the creation and verification of JWTs for user authentication and authorization. JWTs are a secure way to represent claims (like user information) in a compact and self-contained format, often used for session management and access control.

6. morgan:

  • Purpose: Logs incoming HTTP requests and responses, providing valuable information for debugging, monitoring, and security analysis.

7. passport:

  • Purpose: Provides a framework for implementing various authentication strategies, including local authentication, social logins (e.g., Google login), and more. It simplifies the process of handling user login, registration, and authorization.

8. passport-google-oauth20:

  • Purpose: Enables the integration of Google Sign-In functionality into your application. It simplifies the process of handling the Google login flow, including authorization code verification and user information retrieval.

9. dotenv:

  • Purpose: Allows you to store sensitive information like API keys, database credentials, and other secrets securely outside your codebase, improving security and maintainability.

Create these folders and files as below file structure

Inside app.js

// app.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
require('./passport/passport');
const passport = require('passport');
const configs = require('./configs');
const app = express();
const port = process.env.PORT || 3000;

// setting up the middlewares
app.use(express.json());
app.use(require('cors')());
app.use(require('morgan')('dev'));
app.use(passport.initialize());

// Connect to MongoDB
mongoose.connect(configs.dbURL);
const db = mongoose.connection;
db.on('error', console.error.bind(console, 'MongoDB connection error:'));
db.once('open', () => {
  console.log('Connected to MongoDB');
});

// routes
app.use('/auth', require('./routes/auth'));

// Start the server
app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

Create a .env file and store the below code,

MONGODB_URL=yourmongourl
JWT_SECRET=yoursecret
GOOGLE_AUTH_CLIENT_ID= // get it from https://console.cloud.google.com/apis/credentials
GOOGLE_AUTH_CLIENT_SECRET= // https://console.cloud.google.com/apis/credentials
GOOGLE_AUTH_SERVER_CALLBACK=http://localhost:3000/auth/google/callback // server callback url
GOOGLE_AUTH_CLIENT_URL_SUCCESS=http://localhost:5173 // client url

Add the below code inside routes > auth.js

const router = require('express').Router();
const bcrypt = require('bcryptjs');
const User = require('../model/User');
const passport = require('passport');
const jwt = require('jsonwebtoken');
const { verifyToken } = require('../middlewares/auth');
const configs = require('../configs');

// Signup Route
router.post('/signup', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Find the user in the database
    const user = await User.findOne({ username });

    if (user) {
      return res.status(401).json({ error: 'User already registered!' });
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create a new user
    const newUser = new User({ username, password: hashedPassword });

    // Save the user to the database
    await newUser.save();

    res.status(201).json({ message: 'User created successfully' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Login Route
router.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Find the user in the database
    const user = await User.findOne({ username });

    if (!user) {
      return res.status(401).json({ error: 'Invalid username or password' });
    }

    // Compare the password
    const isPasswordValid = await bcrypt.compare(password, user.password);

    if (!isPasswordValid) {
      return res.status(401).json({ error: 'Invalid username or password' });
    }

    // Create and send JWT token
    const token = jwt.sign(
      { userId: user._id, username: user.username },
      'secretKey'
    );
    res.status(200).json({ token });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Google authentication route
router.get(
  '/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);

// Google callback route
router.get(
  '/google/callback',
  passport.authenticate('google', { session: false, failureRedirect: '/' }),
  (req, res) => {
    // Successful authentication, send a token
    const token = jwt.sign(
      { userId: req.user._id, username: req.user.username },
      'secretKey'
    );
    res.redirect(
      `${configs.googleAuthClientSuccessURL}/success?token=${token}`
    );
  }
);

// Success route
router.get('/success', (req, res) => {
  const { token } = req.query;
  // Render a success page or send a response with the token
  res.json({ message: 'Authentication successful', token });
});

// Protected Route
router.get('/isAuthenticated', verifyToken, (req, res) => {
  res.status(200).json({
    message: 'This is a protected endpoint',
    user: req.user,
  });
});

module.exports = router;

Inside passport > passport.js

const configs = require('../configs');
const User = require('../model/User');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// Serialize and Deserialize User for session management
passport.serializeUser((user, done) => {
  done(null, user.id);
});

passport.deserializeUser((id, done) => {
  User.findById(id, (err, user) => {
    done(err, user);
  });
});

// Use GoogleStrategy for OAuth 2.0 authentication
passport.use(
  new GoogleStrategy(
    {
      clientID: configs.googleAuthClientId,
      clientSecret: configs.googleAuthClientSecret,
      callbackURL: configs.googleAuthServerCallbackURL, // Update with your callback URL
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // Check if the user already exists in the database
        let user = await User.findOne({ googleId: profile.id });
        if (!user) {
          // If not, create a new user
          user = new User({
            email: profile.emails[0].value,
            username: profile.displayName,
            googleId: profile.id,
            profilePicture: profile.photos[0].value,
          });
          await user.save();
        }
        return done(null, user);
      } catch (error) {
        return done(error, null);
      }
    }
  )
);

The User.js (user model) should contain the code below

const { default: mongoose } = require('mongoose');

const userSchema = new mongoose.Schema({
  username: { type: String, required: true },
  email: { type: String, required: true },
  password: { type: String },
  googleId: { type: String, unique: true },
  profilePicture: { type: String },
});

module.exports = mongoose.model('User', userSchema);

Inside middleware > auth.js

const jwt = require('jsonwebtoken');

// Middleware to verify JWT token
exports.verifyToken = (req, res, next) => {
  const authHeader = req.headers.authorization;

  const token = authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Unauthorized - Missing token' });
  }

  jwt.verify(token, 'secretKey', (err, decoded) => {
    if (err) {
      return res.status(401).json({ error: 'Unauthorized - Invalid token' });
    }

    req.user = decoded;
    next();
  });
};

Inside configs > config.js

const configs = {
  dbURL: process.env.MONGODB_URL,
  googleAuthClientId: process.env.GOOGLE_AUTH_CLIENT_ID,
  googleAuthClientSecret: process.env.GOOGLE_AUTH_CLIENT_SECRET,
  googleAuthServerCallbackURL: process.env.GOOGLE_AUTH_SERVER_CALLBACK,
  googleAuthClientSuccessURL: process.env.GOOGLE_AUTH_CLIENT_URL_SUCCESS,
};

module.exports = configs;

Now that we're done with our backend application, let's dive into the frontend of our app.

Frontend setup

We'll be using Vite to create our react application, and we're going to need the following packages,

  • react-router-dom (for routing)

  • tailwindcss (for styling)

  • axios (for API calls)

  • dotenv (for storing secure data)

Make sure your folder structure looks like below

inside index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import Home from './Home.jsx';
import Success from './Success.jsx';
import AppContextProvider from './context/AppContext.jsx';
import PrivateRoute from './PrivateRoute.jsx';

const router = createBrowserRouter([
  {
    path: '/',
    element: <App />,
  },
  {
    path: '/home',
    element: (
      <PrivateRoute>
        <Home />
      </PrivateRoute>
    ),
  },
  {
    path: '/success',
    element: <Success />,
  },
]);

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <AppContextProvider>
      <RouterProvider router={router} />
    </AppContextProvider>
  </React.StrictMode>
);

PrivateRoute.jsx

/* eslint-disable react/prop-types */
import { Suspense } from 'react';
import useApp from './hooks/useApp';
import { Navigate } from 'react-router-dom';

const PrivateRoute = ({ children }) => {
  const { token } = useApp();

  return (
    <Suspense fallback={<p>Loading...</p>}>
      {token ? children : <Navigate to={'/'} />}
    </Suspense>
  );
};

export default PrivateRoute;

App.js

import './App.css';
import { Link } from 'react-router-dom';

function App() {
  const loginWithGoogle = async () => {
    window.location.href = `http://localhost:3000/auth/google`; // replace with env
  };

  return (
    <>
      <h1>Hello world</h1>
      <Link to='/home'>Home</Link>
      <br />

      <button onClick={loginWithGoogle} className='mt-4'>
        Login with google
      </button>
    </>
  );
}

export default App;

Home.jsx

import { useEffect, useState } from 'react';
import useApp from './hooks/useApp';
import { axiosClient } from './services/apiClient';

const Home = () => {
  const { token, logout } = useApp();
  const [user, setUser] = useState(null);

  useEffect(() => {
    (async () => {
      try {
        const { data } = await axiosClient.get('/isAuthenticated');

        setUser(data.user);
      } catch (err) {
        console.log(err.response);
      }
    })();
  }, [token]);

  return (
    <div className='p-4'>
      <h1 className='text-2xl'>{user?.username}</h1>
      <p className='text-zinc-500'>This is a protected page.</p>
      <button onClick={logout} className='mt-4'>
        Logout
      </button>
    </div>
  );
};

export default Home;

Inside context > AppContext.jsx

/* eslint-disable react/prop-types */
import { createContext, useEffect, useState } from 'react';

export const AppContext = createContext(null);

export default function AppContextProvider({ children }) {
  const [token, setToken] = useState(localStorage.getItem('token') || null);

  const logout = () => {
    setToken(null);
    localStorage.removeItem('token');
  };

  useEffect(() => {
    if (token) localStorage.setItem('token', token);
  }, [token]);

  return (
    <AppContext.Provider value={{ token, setToken, logout }}>
      {children}
    </AppContext.Provider>
  );
}

hooks > useApp.js

import { useContext } from 'react';
import { AppContext } from '../context/AppContext';
const useApp = () => useContext(AppContext);
export default useApp;

services > apiClient.js

import axios from 'axios';

const token = localStorage.getItem('token') || null;

export const axiosClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
});

axiosClient.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error) {
    let res = error.response;
    console.error(`Looks like there was a problem. Status Code: ${res.status}`);
    return Promise.reject(error);
  }
);

Create .env and put the below code

VITE_API_URL=http://localhost:3000/auth

Run your frontend and backend, happy hacking!