Single Sign-On, JWT Authentication, and NodeJS

Single Sign-On, JWT Authentication, and NodeJS

·

13 min read

In this series of posts, we create a secured end-to-end JWT-based authentication mechanism using NodeJS, Express, PassportJS and React.

In this series I cover:

In this part, we will use what we've accomplished so far and turn it into a complete solution by adding support in multiple SSO strategies using NodeJS, Express, PassportJS. If you haven't read the previous parts, I strongly encourage you to do so before proceeding with this part, as it contains an important background that will help you grasp the concepts we use here.

What is a Single Sign-On Authentication?

We all use Single Sign-On authentication every day. When we subscribe to a new website for the first time, there are usually several ways to choose how to complete the registration process:

  • Username & password - we provide our credentials and a new user that is associated with our email address is created for us
  • ”Sign up with ...” - we use an existing account we own (Google, Facebook, Twitter, Github, Apple, etc)

Whenever we chose the second option, we are redirected to the selected provider, which asks our permission to share our data with the service we came from. This is called Single Sign-On (SSO). Using SSO, we can log into a website without entering a username and password.

I'm not going to dig into the protocol itself in this post, as there's plenty of information available online, but I do encourage you to learn it yourself. Here's a nice post explaining SSO in more depth.

Wait a minute...

"But... how can it work given that we have to rely on a third party to manage our user's authentication, and the JWT is being generated by our backend?"

Great question! This is exactly what this entire post is all about! Our strategy will be as follows:

  1. Configure PassportJS strategies for each SSO provider we support
  2. Let the user authenticate using their provider of choice
  3. Upon successful login, update the user in the DB, generate a refresh token, attach it to a cookie and redirect the user back to the application
  4. Use the refresh token to generate an access token for the user

Prerequisites

Each provider we support requires registration using a dedicated developer account. When you complete the registration process, you will get a Client ID and a Secret ID. Make sure to save them and keep them safe, we will need them later.

Follow the links below to create and configure your SSO access with the two providers we use in this post:

And now, without further ado...

Let's Code

The app configuration

Let's start by adding our providers to the backend's config. I'm using convict to manage the configuration, but you can replace it with whatever you are comfortable with. convict pulls the sensitive data (e.g Client ID, Secret ID) from the application's .env file.

// src/config/config.ts

import convict from 'convict';

export default convict({
  env: {
    default: 'dev',
    env: 'NODE_ENV',
  },
  http: {
    port: {
      doc: 'The port to listen on',
      default: 3001,
      env: 'PORT',
    },
    host: {
      default: 'http://localhost',
      env: 'HOST',
    },
  },
  authentication: {
    linkedin: {
      clientID: {
        doc: 'The Client ID from Google to use for authentication',
        default: '',
        env: 'LINKEDIN_CLIENT_ID',
      },
      clientSecret: {
        doc: 'The Client Secret from Google to use for authentication',
        default: '',
        env: 'LINKEDIN_SECRET',
      },
      scope: {
        default: ['r_emailaddress', 'r_liteprofile'],
      },
    },
    github: {
      clientID: {
        doc: 'The Client ID from Github to use for authentication',
        default: '',
        env: 'GITHUB_CLIENT_ID',
      },
      clientSecret: {
        doc: 'The Client Secret from Github to use for authentication',
        default: '',
        env: 'GITHUB_SECRET',
      },
      scope: {
        default: [],
      },
    },
    token: {
      secret: {
        doc: 'The signing key for the JWT',
        default: 'mySuperSecretKey',
        env: 'JWT_SECRET',
      },
      issuer: {
        doc: 'The issuer for the JWT',
        default: '',
      },
      audience: {
        doc: 'The audience for the JWT',
        default: '',
      },
      expiresIn: {
        doc: 'expressed in seconds or a string describing a time span zeit/ms.',
        default: '24h',
        env: 'JWT_EXPIRES_IN',
      },
    },
    refreshToken: {
      expiresIn: {
        doc: 'expressed in seconds or a string describing a time span zeit/ms.',
        default: '24h',
        env: 'REFRESH_JWT_EXPIRES_IN',
      },
    },
  },
}).validate();

Authentication logic using PassportJS

As you already know, we use PassportJS to declare the authentication strategies of our application. In part 1, we created our first passport strategy - the JWT strategy.

PassportJS supports multiple authentication strategies out of the box. Let's add two new strategies for Github and Linkedin using passport-github2and passport-linkedin-oauth2 respectively.

// src/auth/providers/github.ts

import { Strategy as GithubStrategy } from 'passport-github2';
import { getConfigByProviderName, processUserFromSSO } from '../index';
import passport from 'passport';
import { VerifiedCallback } from 'passport-jwt';
import { Request } from 'express';

const providerName = 'github';
const passportConfig = getConfigByProviderName(providerName);

if (passportConfig.clientID) {
  passport.use(
    new GithubStrategy(
      { ...passportConfig, passReqToCallback: true },
      (req: Request, accessToken: string, refreshToken: string, profile: any, verified: VerifiedCallback) => {
        processUserFromSSO(req, profile, providerName, verified);
      },
    ),
  );
}
// src/auth/providers/linkedin.ts

import { Strategy as LinkedInStrategy } from 'passport-linkedin-oauth2';
import { getConfigByProviderName, processUserFromSSO } from '../index';
import passport from 'passport';
import { VerifiedCallback } from 'passport-jwt';
import { Request } from 'express';

const providerName = 'linkedin';
const passportConfig = getConfigByProviderName(providerName);

if (passportConfig.clientID) {
  passport.use(
    new LinkedInStrategy(
      { ...passportConfig, passReqToCallback: true },
      (req: Request, accessToken: string, refreshToken: string, profile: any, verified: VerifiedCallback) => {
        processUserFromSSO(req, profile, providerName, verified);
      },
    ),
  );
}

You probably noticed that we use the processUserFromSSO function in both providers. After a successful login, the SSO provider exposes an accessToken, a refreshToken, and a limited profile of the logged-in user. We are not interested in the first two, as we manage the access and refresh tokens ourselves. However, we still need the information from the profile, to identify the user in our system. Once we have that, we can pull the corresponding user object from our database and attach it to the request. If we don't have a match in our DB, it means that this is the user's first time logging in to our system, so we create one for them.

Note: To simplify things, I chose to create a new user object in the DB for each provider, by searching for the user by the origin and the originId. Ideally, to provide a better user experience, we would create one user object to identify an individual across all providers (if the email matches).

Let's rearrange our code from the previous parts, and create the processUserFromSSO function along with more useful utils.

// src/auth/index.ts

import { User, UserModel } from '../models/user';
import fs from 'fs';
import { removeExtensionFromFile } from '../middleware/utils';
import { VerifiedCallback } from 'passport-jwt';
import config from '../config/config';
import passport from 'passport';
import { Request } from 'express';
import { join } from 'path';

export const init = () => {
  const providersPath = join(__dirname, 'providers');
  fs.readdirSync(providersPath).forEach((file) => {
    const authFile = removeExtensionFromFile(file);
    import(join(providersPath, authFile));
  });
};

export const requireAuth = passport.authenticate('jwt', {
  userProperty: 'currentUser',
  session: false,
});

export const getConfigByProviderName = (providerName: string) => {
  return {
    clientID: config.get(`authentication.${providerName}.clientID`),
    clientSecret: config.get(`authentication.${providerName}.clientSecret`),
    scope: config.get(`authentication.${providerName}.scope`),
    callbackURL: getAuthCallbackUrl(providerName),
  };
};

export const processUserFromSSO = (req: Request, profile: any, origin: string, done: VerifiedCallback) => {
  const {
    emails,
    name: { givenName, familyName },
    id,
  } = profile;

  UserModel.findOneAndUpdate(
    { origin, originId: id },
    {
      email: emails?.[0]?.value,
      firstName: givenName,
      lastName: familyName,
      origin,
      originId: id,
    },
    { upsert: true },
    (err, user) => {
      if (err) {
        return done(err);
      }
      req.currentUser = user as User;
      return done(null, user);
    },
  );
};

const getAuthCallbackUrl = (providerName: string) => {
  return `${config.get('http.host')}:${config.get('http.port')}/api/auth/${providerName}/callback`;
};

Express routes for SSO authentication

To wrap things up, we loop through the providers we configured and add two routes for each one of them:

  • /auth/${providerName} - initiates the authentication process. The UI calls this API to let the backend know that the user chose to authenticate using SSO. The backend will reach the relevant provider to initiate the process and will redirect the user to the provider's login page
  • /auth/${providerName}/callback - the provider uses this route to redirect the user back to the application once the authentication process is completed. At this point, the user is authenticated, and we need to attach the access token. Since we can't redirect the user with a token in a secured way, we attach the refresh token to a cookie that will be used by the UI to fetch the access token
// src/routes/auth.ts

import express, { Request, Response } from 'express';
const router = express.Router();
import passport from 'passport';
import config from '../config/config';
import { generateRefreshToken } from '../utils/token-util';

// local-jwt login
// .. rest of the routes from part 1 are here

// social-jwt login
export const generateUserTokenAndRedirect = async (req: Request, res: Response) => {
  const successRedirect = `${process.env.FRONTEND_URL}/authentication/redirect`;
  const { token } = generateRefreshToken(req.currentUser?._id.toString());
  res.cookie('refresh_token', token, { httpOnly: true });
  res.redirect(successRedirect);
};

// loop over the available authentication providers and add a route for them
Object.keys(config.get('authentication') || {}).forEach((providerName) => {
  const failureRedirect = `${process.env.FRONTEND_URL}"`;
  const providerAuthMiddleware = passport.authenticate(providerName, {
    session: false,
    userProperty: 'currentUser',
    failureRedirect,
  });
  router.get(`/auth/${providerName}`, providerAuthMiddleware);
  router.get(`/auth/${providerName}/callback`, providerAuthMiddleware, generateUserTokenAndRedirect);
});

export default router;

That's all for this part. In the next part, we'll complete our journey by adding client-side support in SSO using the infrastructure we created so far. I hope you find this post helpful, let me know what you think!