How to implement JumpCloud SSO to Node.js application using SAML protocol

Hey everyone! In this post, I would like to share with you guys how to implement the SSO to a Node.js application with SAML protocol.

Introduction to SSO and SAML

For those who do not know what a SSO is, SSO or Single Sign On is an authentication method which allows user to sign in or log in into multiple applications or systems using the a single set of credentials. SSO increases the user experience and the most important part is it enhances security by reducing the number of passwords users need to remember and by centralising authentication processes.

SAML (Security Assertion Markup Language) and OpenID are the two main protocols for SSO. OpenID is notable for its decentralised approach, allowing users to use one set of credentials across various websites. In contrast, SAML is widely used in enterprise environments, offering robust authentication solutions. Both protocols aim to simplify user authentication, improving both convenience and security in today's digital landscape.

JumpCloud configuration

There is a lot of things that we have to configure in our JumpCloud account. When I was doing this project, it was a bit of frustrating as they were not many examples and the documentation was a bit confusing to me. Hence, I decided to write this post. You can refer my Github repo for the complete code.

Before we can integrate the SSO to our application, we have to create a custom application in JumpCloud. Simply go to the Admin Console, there is a sidebar on the left. We will see a section named User Authentication, then click on SSO Application. Click on Create a new application and you will be directed to this page:

Now, we select the Custom Application and this option allows us to integrate our application with SSO. There will be several features available, simply click on Manage Single-Sign-On (SSO) as in the photo below:

Click Next and then we can add logo for your application or we can leave it as the default. Next click on Saved Application button.

Now, our application is ready to be integrated. All we have to do is to configure some information in our custom application.

Go to the SSO Applications tab and click on our created custom application.

Click on the SSO tab, and here is where we configure everything.

Most of the fields are going to be left as default. The fields that we need to change are:

  1. SP Entity ID

  2. ACS URL

  3. Login URL

  4. Attributes (optional)

SP Entity ID refers to Service Provider ID which is unique for the SAML entity. In short word, SP is our own application. Hence, the ID should be our application URL/sso. For example, http://your-web-url.com/sso.

ACS URL stands for Assertion Customer Service URL which is responsible to send the SAML response. Here, we set it as https://your-web-url.com/login/callback.

Login URL is the path to our login page in our own application.

Attributes are the fields that are sent in our SAML response. This option can be used to store session for instance. You can put anything for the Service Provider Attribute Name for this case. Just make sure they are tally with JumpCloud attributes to avoid confusion.

Now, we are done configuring our JumpCloud for our application. Before we leave the console, we need to do another two things; download the IDP certificate and take note of IDP URL.

IDP certificate can be found on the left side of our application settings:

This certificate is important as our Identity Provider (IdP) which is in this case JumpCloud will use it to verify the signature of the SAML responses.

The IDP URL is the URL where users will be directed to for the authentication. This is the IdP's SSO URL. We can obtain this URL at the SSO tab:

Finally we can continue working on our coding.

Create your Node.js

As usual, we create our Node.js application by using these commands:

npm init -y

We also need to install Express.js for session authorisation, Passport.js for authentication and authorisation purposes, fs to read the certificate.

npm install express passport fs express-session

There are some libraries or packages you might need, but these packages above are explicitly needed for this particular situation. I also would like to highlight that I will not be implementing the JWT as this application is only to demonstrate how the SSO works.

Here, we create a config.js for our JumpCloud configuration where we add all configuration we set on JumpCloud:

const fs = require('fs');

module.exports = {
  passport: {
    strategy: 'saml',
    saml: {
      path: 'https://your-web-url.com/login/callback',
      entryPoint:
        process.env.SAML_ENTRY_POINT ||
        'https://sso.jumpcloud.com/saml2/systemname',
      issuer: 'passport-saml',
      cert: fs.readFileSync('your-jumpcloud-sso-certrificate.cer', 'utf-8')
    },
  },
};

Next, we create a file named passport.js. This file is used to configure our Passport.js library along with SAML configurations. Block of code below is the example of the configuration:

const SamlStrategy = require('passport-saml').Strategy;
const config = require('./config');

module.exports = function (passport) {
  passport.serializeUser(function (user, done) {
    done(null, user);
  });

  passport.deserializeUser(function (user, done) {
    done(null, user);
  });

 passport.use(
  new SamlStrategy(config.passport.saml, function (profile, done, req) {
    try {
      // Access the firstName from the SAML profile
      const { uid, email, firstName } = profile;

      // The `info` parameter is added to the callback
      return done(null, {
        id: uid,
        email: email,
        firstName: firstName,
      }, { message: 'Authentication successful', profile: profile });
    } catch (error) {
      console.error('Error in SAML authentication:', error);
      return done(error);
    }
  })
);
}

Here we configure Passport.js for SAML authentication where its strategy and configuration settings, sets up functions to serialise and deserialise user objects, and defines the SAML authentication strategy. The strategy extracts user information from the SAML profile, constructs a user object, and passes it to Passport.js for authentication.

After that, we now can integrate our SSO using Passport. We configure routes for handling SSO authentication, initiating login, and processing the login callback.

We start by importing all the libraries needed as well as the config and passport modules we just created:

const express = require('express');
const app = express();
const passport = require('passport');
const PORT = process.env.PORT || 3500;
const config = require('./config');
require('./passport')(passport);

Next, we add middleware where passport.initialize() to initialise Passport. passport.session() middleware to support persistent login sessions.

app.use(passport.initialize());
app.use(passport.session());

We add this route to handle SSO authentication callback. When the SAML response is posted back to the server, this route processes it passport.authenticate(config.passport.strategy, ...) where it authenticates the incoming SAML response. If authentication fails, it redirects to the home page (/). On successful authentication, it redirects to the home page.

app.post(
  config.passport.saml.path,
  passport.authenticate(config.passport.strategy, {
    failureRedirect: '/',
    failureFlash: true,
  }),
  function (req, res) {
    res.redirect('/');
  }
);

Then, we initialise the route for SSO login process. When a GET request is made to /login, it triggers the SAML authentication process.

app.get('/login', (req, res) => {
  passport.authenticate('saml', {
    failureRedirect: '/login',
    failureFlash: true,
  })(req, res);
});

At last, we add this route to process our login callback. It handles the callback from the SAML provider after the user has authenticated. passport.authenticate('saml', ...) is to process the SAML response. It logs the authentication information and handles errors. If an error occurs, it logs the error and renders the login page with an error message. If no user is found, it logs the message and renders the login page with an appropriate error. If authentication is successful, req.logIn(user, ...) logs in the user and establishes a session. It sets the username in the session and redirects to the home page (/home).

app.post('/login/callback', (req, res, next) => {
  passport.authenticate('saml', (err, user, info) => {
    try {
      console.log('SAML Authentication Info:', info);

      if (err) {
        console.error('Error in SSO authentication:', err);
        return res.render('login', { error: 'SSO authentication failed' });
      }

      if (!user) {
        console.error('User not found');
        return res.render('login', { error: 'User not found' });
      }

      req.logIn(user, function (loginErr) {
        if (loginErr) {
          console.error('Error logging in:', loginErr);
          return res.render('login', { error: 'Login failed' });
        }
        req.session.user = info?.profile?.username;
        return res.redirect('/home');
      });
    } catch (error) {
      console.error('Error in SSO callback:', error);
      return res.render('login', { error: 'SSO callback failed' });
    }
  })(req, res, next);
});

So now, our application will use the JumpCloud SSO. When our user login, they will be directed to this page:

As we can see in our URL tab, it includes SAML and SSO. We can also can verify this by using a Chrome Extension named SAML-tracker. It will show SAML like in the photo below to show that this application is using SAML protocol:

Once user successfully logs in, they will be directed to this page:

Here is some applications that this user bounds to. This user can go to any of these applications without having to log in twice.

This is an example of SAML response:

SAML Authentication Info: {
  message: 'Authentication successful',
  profile: {
    issuer: 'https://your-web-url.com/sso',
    inResponseTo: '_xxxxxxxxxxxxx',
    sessionIndex: 'xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx',
    nameID: 'example@abc.com',
    nameIDFormat: 'urn:oasis:names:tc:SAML:1.0:nameid-format:unspecified',
    nameQualifier: undefined,
    spNameQualifier: undefined,
    email: 'example@abc.com',
    username: 'example',
    attributes: { email: 'example@abc.com', username: 'example' },
    getAssertionXml: [Function (anonymous)],
    getAssertion: [Function (anonymous)],
    getSamlResponseXml: [Function (anonymous)]
  }
}

And, that's it! Now we successfully integrate JumpCloud SSO with our application. Yeay! 🥳

References:

  1. https://www.passportjs.org/packages/passport-saml/

  2. https://auth0.com/intro-to-iam/saml-vs-openid-connect-oidc

  3. https://jumpcloud.com/support/sso-using-custom-saml-application-connectors