SAML SSO implementation example

Not that long ago I needed to implement an SSO solution. I had two objectives - it had to support SAML and I should use one of the existing libraries to do so.

After doing initial research and settling on the library (spoiler alert! passport-saml - it has decent documentation) I found many articles that explained how SAML works but not much on how to implement it. That’s not surprising given that in our industry for every problem there are thousands of solutions and a multitude of libraries to help you implement them. It did, however, make my job a little harder, since it was my first time working with SSO.

This article will focus on implementing an SSO solution using passport-saml and Node.js. A little disclaimer - I’m not saying my solution is the ultimate one or that it will work for you, this is just a real-life example of how it can be implemented. Hopefully, if you are tasked with implementing SSO you have something of a reference or at least it will help you understand it a bit better.

I assume you know how SAML works - if not check out this fantastic article “The Beer Drinker’s Guide to SAML”.

Top tip - Since you will have to test your code you will need to set up an Identity Provider as well. I used Auth0 as it has easy to follow documentation, it’s simple to implement and has great debugging capabilities.

Let’s begin!

After installing the library, the next step is to configure a strategy. For my project, it made sense to use a multiple providers strategy (meaning I can accept multiple identity providers).

// saml strategy for passport
const strategy = new MultiSamlStrategy(
   {
       passReqToCallback: true, // makes req available in callback
       getSamlOptions(request, done) {
           // supports fetching config by user email or idp id
           getSamlConfig(request, function(err, provider) {
               if (err) {
                   return done(err);
               }
               return done(null, provider);
           });
       }
   },
   (req, profile, done) => {
       return done(null, profile);
   }
);

Now I need to implement the getSamlConfig function.

const getSamlConfig = (request, callback) => {
   let userEmail = null;
   let orgId = null;
   const email = request.query.email;

   if (typeof email !== "undefined") {
       //validate email
       if (email.match(validator.email) === null) {
           return callback({
               "statusCode": 404,
               "message": `Invalid email`
           });
       } else {
           userEmail = email;
       }
   }
   // the solution supports requests by email or organisation id
   if (typeof request.body !== "undefined") {
       if (_.isString(request.body.RelayState)) {
           orgId = parseInt(tools.sanitize(request.body.RelayState), 10);
       } else if (_.isString(request.body.SAMLResponse)) {
           const parser = new Saml2js(request.body.SAMLResponse);
           const userObject = parser.toObject();
           // extract email from SAML response
           if (typeof userObject.emailAddress === "string") {
               userEmail = userObject.emailAddress;
           }
       }
   }
   if (orgId === null && userEmail === null) {
       return callback({
           "statusCode": 500,
           "message": `Missing email and/or RelayState`
       });
   }

   // helper function to fetch identity provider details from database
   getSSOSettings(userEmail, orgId, function (err, ssoData) {
       if (err) {
           return callback(err);
       } else {
           return callback(null, {
               issuer: ssoData.sp_entity_id,
               audience: ssoData.sp_entity_id,
               protocol: 'https://',
               signatureAlgorithm: "sha256",
               digestAlgorithm: "sha256",
               wantAssertionsSigned: true,
               xmlSignatureTransforms: ['http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'],
               path: ssoData.idp_callback_url,
               entryPoint: ssoData.idp_url,
               cert: ssoData.certificate,
               acceptedClockSkewMs: 300000, // 5 min
               maxAssertionAgeMs: 300000,
               additionalParams: {
                   'RelayState': ssoData.id
               }
           });
       }
   });
};

This function is used every time passport.authenticate() is called: by the front end and by the Identity Provider at two separate stages in the login flow in my application. Hence it supports extracting identifier data (either the user's email or organisation id) in multiple ways. You may also notice the email variable coming from SAML response is userObject.emailAddress - one thing that I learned the hard way is that the names (SAML attributes) are set by the identity providers as well and have to be agreed upon when integrating with them. Some may call it email or userEmail etc. So whenever you see: SAML provider returned Requester error: Cannot provide requested name identifier with format urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress for the given subject. Check that what you configured matches what the identity provider sends you!

We now have a strategy and function that fetches the SSO configuration. Great! The next step is to create our endpoints. First, we need an entry point to trigger the user authentication.

/*
Log in User with SAML SSO
GET /user/sso
*/
router.get("/", function(req, res, next) {
   passport.authenticate('saml', {
       failureRedirect: "/login", //redirect user back to login screen in case of failure
       failureMessage: true,
       session: false
   })(req, res, next);
   // catch errors by passing them to the handler as the next route function.
}, passportErrorHandler);

For error handling, I opted to redirect the user back to my service and the front end would intercept the errors.

const passportErrorHandler = (err, req, res, next) => {
res.redirect(`${config.url}/login?errCode=${err.statusCode}&errMessage=${err.message}`);
};

Now for the final step between the Identity Provider and the Service Provider. We need a way to receive SAML assertions once the user has been authenticated.

/*
POST /sso/login
*/
router.post('/login',
   bodyParser.urlencoded({ extended: false }),
   passport.authenticate('saml', { failureRedirect: '/login', failureMessage: true, session: false }), (req, res, next) => {
       const parser = new Saml2js(req.body.SAMLResponse);
       req.RelayState = parseInt(req.body.RelayState, 10);
       req.samlUserObject = parser.toObject();
       next();
   }, function (req, res) {
       // In my project once user is authenticated we create a short lived token
       // I chose not to show that code - you'll have to imagine what it looks like ;)
       controller.createOTPtoken(req.samlUserObject, req.RelayState, function (err, otp_token) {
           if (err) {
               res.redirect(`${config.url}/login?errCode=${err.statusCode}&errMessage=${err.message}`);
           } else {
               res.redirect(`${config.url}/login?ssoOtp=${otp_token}`);
           }
       });
   });

I've opted to after we receive the SAML assertion to create a short-lived token the Front End can then use to authenticate the user and fetch appropriate user data.

Before you implement your solution, it's worth reading up on the OWASP top 10 to verify what vulnerabilities you are opening yourself up to with SSO and the best ways to protect yourself. Make sure to continuously update the libraries you chose to implement as any vulnerability fixes you miss - bad actors can easily exploit. With SAML SSO it's worth adding the 'audience' param. Which is a fancy way of saying " This SAML assertion is only meant for this Service Provider".

I hope this was helpful. Best of luck with your project!