DEV Community

loading...

SSO + Express JS + Passport-saml

Mitesh Kamat
Front End Engineer | Big fan of JavaScript
Updated on ・6 min read

Introduction

In my previous post, I had mentioned about decoding saml response.
Going ahead with that I integrated single-sign-on with my React JS application.
I have Express JS as my routing middleware and Passport as my authentication middleware.

To understand how to configure passport js check these links:
http://www.passportjs.org/
https://github.com/bergie/passport-saml

At the end of this, I had following files in my express middleware:

  1. app.js - This holds my server side code which is using express js
  2. config.js - login/ logout configuration
  3. provider.xml - It holds xml metadata (SP and IDP details)
  4. reader.js - Reading metadata from xml
  5. private.pem - Certificate for authentication
  6. passport.js - Configuration for passport middleware.

Read IDP and SP configuration in app.js file from provided xml file

const fs = require('fs'),
const reader = new MetadataReader(
  fs.readFileSync('./spsit.xml', 'utf8') //Read IDP and SP details from xml 
);

Notice the MetadataReader, we need a reader functionality. Create a file reader.js and add the following code.

const assert = require('assert');
const debug = require('debug')('passport-saml-metadata');
const camelCase = require('lodash/camelCase');
const merge = require('lodash/merge');
const find = require('lodash/find');
const sortBy = require('lodash/sortBy');
const { DOMParser } = require('xmldom');
const xpath = require('xpath');

const defaultOptions = {
  authnRequestBinding: 'HTTP-Redirect',
  throwExceptions: false
};

class MetadataReader {
  constructor(metadata, options = defaultOptions) {
    assert.equal(typeof metadata, 'string', 'metadata must be an XML string');
    const doc = new DOMParser().parseFromString(metadata);

    this.options = merge({}, defaultOptions, options);

    const select = xpath.useNamespaces({
      md: 'urn:oasis:names:tc:SAML:2.0:metadata',
      claim: 'urn:oasis:names:tc:SAML:2.0:assertion',
      sig: 'http://www.w3.org/2000/09/xmldsig#'
    });

    this.query = (query) => {
      try {
        return select(query, doc);
      } catch (e) {
        debug(`Could not read xpath query "${query}"`, e);
        throw e;
      }
    };
  }

  get identifierFormat() {
    try {
      return this.query('//md:IDPSSODescriptor/md:NameIDFormat/text()')[0].nodeValue;
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get identityProviderUrl() {
    try {
      // Get all of the SingleSignOnService elements in the XML, sort them by the index (if provided)
      const singleSignOnServiceElements = sortBy(this.query('//md:IDPSSODescriptor/md:SingleSignOnService'), (singleSignOnServiceElement) => {
        const indexAttribute = find(singleSignOnServiceElement.attributes, { name: 'index' });

        if (indexAttribute) {
          return indexAttribute.value;
        }

        return 0;
      });

      // Find the specified authentication binding, if not available default to the first binding in the list
      const singleSignOnServiceElement = find(singleSignOnServiceElements, (element) => {
        return find(element.attributes, {
          value: `urn:oasis:names:tc:SAML:2.0:bindings:${this.options.authnRequestBinding}`
        });
      }) || singleSignOnServiceElements[0];

      // Return the location
      return find(singleSignOnServiceElement.attributes, { name: 'Location' }).value;
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get logoutUrl() {
    try {
      // Get all of the SingleLogoutService elements in the XML, sort them by the index (if provided)
      const singleLogoutServiceElements = sortBy(this.query('//md:IDPSSODescriptor/md:SingleLogoutService'), (singleLogoutServiceElement) => {
        const indexAttribute = find(singleLogoutServiceElement.attributes, { name: 'index' });

        if (indexAttribute) {
          return indexAttribute.value;
        }

        return 0;
      });

      // Find the specified authentication binding, if not available default to the first binding in the list
      const singleLogoutServiceElement = find(singleLogoutServiceElements, (element) => {
        return find(element.attributes, {
          value: `urn:oasis:names:tc:SAML:2.0:bindings:${this.options.authnRequestBinding}`
        });
      }) || singleLogoutServiceElements[0];

      // Return the location
      return find(singleLogoutServiceElement.attributes, { name: 'Location' }).value;
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get encryptionCerts() {
    try {
      return this.query('//md:IDPSSODescriptor/md:KeyDescriptor[@use="encryption" or not(@use)]/sig:KeyInfo/sig:X509Data/sig:X509Certificate')
        .map((node) => node.firstChild.data);
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get encryptionCert() {
    try {
      return this.encryptionCerts[0];
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get signingCerts() {
    try {
      return this.query('//md:IDPSSODescriptor/md:KeyDescriptor[@use="signing" or not(@use)]/sig:KeyInfo/sig:X509Data/sig:X509Certificate')
        .map((node) => node.firstChild.data);
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get signingCert() {
    try {
      return this.signingCerts[0];
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      } else {
        return undefined;
      }
    }
  }

  get claimSchema() {
    try {
      return this.query('//md:IDPSSODescriptor/claim:Attribute/@Name')
        .reduce((claims, node) => {
          try {
            const name = node.value;
            const description = this.query(`//md:IDPSSODescriptor/claim:Attribute[@Name="${name}"]/@FriendlyName`)[0].value;
            const camelized = camelCase(description);
            claims[node.value] = { name, description, camelCase: camelized };
          } catch (e) {
            if (this.options.throwExceptions) {
              throw e;
            }
          }
          return claims;
        }, {});
    } catch (e) {
      if (this.options.throwExceptions) {
        throw e;
      }
      return {};
    }
  }
}

module.exports = MetadataReader;

Create a configuration file config.js which includes login, logout url and other configuration requirements

const fs = require('fs');
const spPrivateKey = fs.readFileSync('./private.pem','utf8');
module.exports = {
  callbackUrl: `http://localhost:5000/login/callback`,  // express-server-url
  logoutCallbackUrl: `http://localhost:3300/auth/saml/slo/callback`,
  issuer: 'urn:test:abc:mumbai',
  privateCert: spPrivateKey
};

This configuration is required for the authentication strategy which will be used while configuring passport JS.

My passport.Js file is as below:

const debug = require('debug')('passport-saml-metadata');

function toPassportConfig(reader = {}, options = { multipleCerts: false }) {
  const { identifierFormat, identityProviderUrl, logoutUrl, signingCerts } = reader;

  const config = {
    identityProviderUrl,
    entryPoint: identityProviderUrl,
    logoutUrl,
    cert: (!options.multipleCerts) ? [].concat(signingCerts).pop() : signingCerts,
    identifierFormat
  };

  debug('Extracted configuration', config);

  return config;
}

function claimsToCamelCase(claims, claimSchema) {
    const obj = {};

    for (let [key, value] of Object.entries(claims)) {
      try {
        obj[claimSchema[key].camelCase] = value;
      } catch (e) {
        debug(`Error while translating claim ${key}`, e);
      }
    }

    return obj;
  }

  module.exports = {
    toPassportConfig,
    claimsToCamelCase
  };

Next step is to decide which strategy you will be using. Here, I have used saml strategy that also requires you to mention initial configuration for this strategy.
Import toPassportConfig from a file with passport.Js implementation

const { toPassportConfig } = require('./passport');
const ipConfig = toPassportConfig(reader);

const strategyConfig = {
  ...ipConfig,
  ...spConfig,
  validateInResponseTo: false,
  disableRequestedAuthnContext: true,
};

const verifyProfile = (profile, done) => {
  return done(null, { ...profile, test: 'xxx' });
};

const samlStrategy = new SamlStrategy(strategyConfig, verifyProfile);
passport.use('saml',samlStrategy);

Now, we need to define routes in our Express JS app that will handle our API requests.
Let us define the default route handling.

app.get('/',passport.authenticate('saml', { 'successRedirect': '/', 'failureRedirect': '/login' }));

It specifies that we will use saml strategy for authentication.
Next route after authentication.

app.post(
  '/login/callback',
  function(req, res) {
    const xmlResponse = req.body.SAMLResponse;
    decoder.decodeSamlPost(xmlResponse, (err,xmlResponse) => {
      if(err) {
        throw new Error(err);
      } else {
        parseString(xmlResponse, { tagNameProcessors: [stripPrefix] }, function(err, result) {
          if (err) {
            throw err;
          } else {
            console.log(result);

            //sign token
            token = JWT.sign({id: nameID}, keyConfig.secretKey, {
              expiresIn: '24h' //other configuration options
            });
          }
        });
      }
    })
    res.redirect('http://localhost:3000');
  }
);

Let us analyze the above-mentioned route in steps:

  1. Store the SAML response after authentication.
  2. Decode the response and convert it into XML.
  3. Extract token that you might receive and store it for future use.
  4. If you are creating a token, then use jsonwebtoken that is available as npm package to sign/verify/encode/decode.
  5. While signing a token you can send the desired payload that might include credentials, secret key, etc.
  6. Then redirect as per your requirements.

This is my final server side code(app.js)

const express = require('express'), 
  app = express(),
  bodyParser = require('body-parser'),
  cors = require('cors'),
  SamlStrategy = require('passport-saml').Strategy,
  fs = require('fs'),
  passport = require('passport'),
  spConfig = require('./config'),
  JWT = require('jsonwebtoken'),
  keyConfig = require('./keyConfig'),
  decoder = require('saml-encoder-decoder-js'),
  parseString = require("xml2js").parseString,
  stripPrefix = require("xml2js").processors.stripPrefix,
  axios = require('axios'),
  MetadataReader = require('./reader');

const { toPassportConfig } = require('./passport');

app.use(bodyParser.urlencoded({ extended: false })); 
app.set('views', __dirname + '/views'); // general config
app.set('view engine', 'jade');

let corsOptions = {
  origin: 'http://localhost:3000',
  optionsSuccessStatus: 200
}

app.use(cors(corsOptions));
app.use(bodyParser.json());

// passport-saml setup
const reader = new MetadataReader(
  fs.readFileSync('./spsit.xml', 'utf8') //Read IDP and SP details from xml 
);
const ipConfig = toPassportConfig(reader);

const strategyConfig = {
  ...ipConfig,
  ...spConfig,
  validateInResponseTo: false,
  disableRequestedAuthnContext: true,
};

const verifyProfile = (profile, done) => {
  return done(null, { ...profile, test: 'xxx' });
};

const samlStrategy = new SamlStrategy(strategyConfig, verifyProfile);
passport.use('saml',samlStrategy);

let nameID,
  token;

// ---  routes  ---

app.get('/',passport.authenticate('saml', { 'successRedirect': '/', 'failureRedirect': '/login' }));

app.post(
  '/login/callback',
  function(req, res) {
    //req.headers['token'] = token;
    //console.log(req.headers);
    const xmlResponse = req.body.SAMLResponse;
    decoder.decodeSamlPost(xmlResponse, (err,xmlResponse) => {
      if(err) {
        throw new Error(err);
      } else {
        parseString(xmlResponse, { tagNameProcessors: [stripPrefix] }, function(err, result) {
          if (err) {
            throw err;
          } else {

            //sign token
            token = JWT.sign({id: nameID}, keyConfig.secretKey, {
              expiresIn: '24h' //other configuration options
            });
            console.log(result);

          }
        });
      }
    })
    res.redirect('http://localhost:3000');
  }
);

app.get(
  '/logout',
  function(req, res) {
    passport._strategy('saml').logout(req, function(err, requestUrl) {
      req.logout();
      res.redirect('/');
    }
  );
});

const port = process.env.PORT || 5000;
app.listen(port);

console.log('App is listening on port ' + port);

module.exports = app;

I found it difficult to keep a track of this at start. I hope you find this useful.

Appreciate your feedback and guidance ...
Cheers !!!

Discussion (18)

Collapse
sripalinm profile image
Sripalinm • Edited

Hi Mitesh, Initially, Thanks a lot for knowledge sharing, could you please share complete code with git location, and could you please share the link, your blog which mentioned decoding saml response.

Collapse
miteshkamat27 profile image
Mitesh Kamat Author

Hi There,
Thanks for writing. This is the post which I am referring to Parsing namespace. Actually, this code is a part of my team project but let me see if I can create a reproducible repo for the same which will be of help.

Collapse
raysercast1 profile image
raysercast1

Hi MItesh!! I want to know if you could do a reproducible repo? I'm new as a programmer and I was assigned to create a SP and It would be very helpful if I can use a repo as a guide :). Thank a lot for this post!

Collapse
sripalinm profile image
Sripalinm

Hi Mitesh, Really Thanks for the quick and kind response, and If its possible, then it will be the greatest help for me.

Collapse
gspagoni profile image
Giampaolo Spagoni

Hello Mitesh, great article
do you have a github repo for all the code?
thanks

Collapse
miteshkamat27 profile image
Mitesh Kamat Author

Thanks for writing. I have a private repo but yet to create a public repo. Once I'm done with it I'll share in this post.

Collapse
gspagoni profile image
Giampaolo Spagoni

Hello Mitesh, it's me again. i have another question that maybe you can help me out. i used passport-saml for SSO and it worked. now i have to make a ws trust call to get the token back but i have to pass the Assertion on the header. do you have an example how looks like the assertion or i can i do ? thanks in advance

Thread Thread
miteshkamat27 profile image
Mitesh Kamat Author • Edited

Hi There,

Apologies for late response. Did you try passport-jwt package?
var JwtStrategy = require('passport-jwt').Strategy;
var ExtractJwt = require('passport-jwt').ExtractJwt;

And maybe you can create an options object like:
var opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken(); //depends
opts.secretOrKey = config.secretKey;

passport.use(new JwtStrategy(opts,
(jwt_payload, done) => {
console.log('JWT payload', jwt_payload);
}
)));

And if you have specified a login route like this:
router.post('/login', passport.authenticate('saml'), (req, res) => {
var token = jwt.sign({_id: req.user._id}, config.secretKey, {
expiresIn: '1h'
});
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.json({ success: true, token: token, status: 'You are successfully logged in !' })
});
Let me know if this is what you are looking for.

Thread Thread
gspagoni profile image
Giampaolo Spagoni

Thank you Mitesh
thanks for your reply which is good but it answer partially to my request
what I'm looking for is how to create an RTS to post as soap request including the assertion. I'm not very strong in security so forgive me if I don't use the right terminology. in short, after the SSO I need to make a was trust call passing the RST and once I got the RSTR I need to extract the token which I guess is what you wrote above

thanks for your help
GS

Collapse
bankurukodanda profile image
Kodanda • Edited

Hi Mitesh,
small doubt
router.post('/SSO', passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), function (req, res) {
//Logic
});
control is not coming to inside of this function can you please suggest me what is the issue

My passport code

var passport = require('passport');
var SamlStrategy = require('passport-saml').Strategy;

var users = [];

function findByEmail(email, fn) {
for (var i = 0, len = users.length; i < len; i++) {
var user = users[i];
if (user.email === email) {
return fn(null, user);
}
}
return fn(null, null);
}

passport.serializeUser(function(user, done) { //console.log('inside seriliaze');console.log(user.Email);
done(null, user.Email);
});

passport.deserializeUser(function(id, done) { //console.log('deserialized');
findByEmail(id, function (err, user) {
done(err, user);
});
});

passport.use(new SamlStrategy(
{
issuer: "",
path: '/healthCheck',
entryPoint: "
",
cert: "**"
},
function(profile, done) {
//console.log('inside Saml Strategy');console.log(profile.Email);
if (!profile.Email) {
return done(new Error("No email found"), null);
}
process.nextTick(function () {
findByEmail(profile.Email, function(err, user) {
if (err) {
return done(err);
}
if (!user) {
users.push(profile);
return done(null, profile);
}
return done(null, user);
})
});
}, function (err){
console.log(err);
}
));

passport.protected = function protected(req, res, next) {//console.log('inside protected');
if (req.isAuthenticated()) {
return next();
}

res.redirect('/healthCheck');
};

exports = module.exports = passport;

Collapse
miteshkamat27 profile image
Mitesh Kamat Author

Can you try adding a middleware for router.post('/SSO', authMiddleware);

module.exports = function authMiddleware(req, res, next) {
  req.query.RelayState = req.headers.referer;
  console.log("referer", req.headers);
  passport.authenticate('saml')(req, res, next);
}

Enter fullscreen mode Exit fullscreen mode

Check if the control reaches here.
I saw this issue while implementation. As per the official documentation it should work but the control never reaches the success and failure part. So, we have added a middleware to get through this. Let me know if this helps.

Collapse
alexbran8 profile image
alexbran8

Hello! thanks for this awesome tutorial! Could you please let me know, if I need to send a XML metadata URL to the idp, how can I achieve this?
I am using the entity_ID of another application(django) which I need to pass to idP?

for django saml the parameter is "entity_id"...

Collapse
miteshkamat27 profile image
Mitesh Kamat Author

Thanks for writing. At the moment I have an xml file with metadata in my local setup, but yes considering different environments we would need to send the xml metadata url to idp to have required metadata. I am yet to implement it for production level. If I figure it out , then I'll post it here. I hope I understood your question so that I can provide you my configuration setup.

Collapse
miteshkamat27 profile image
Mitesh Kamat Author

router.get('/metadata', function(req, res){
  const decryptionCert = //certificate goes here
  res.type('application/xml');
  res.send(strategy.generateServiceProviderMetadata(decryptionCert,decryptionCert));
 }
);
Enter fullscreen mode Exit fullscreen mode
Collapse
sripalinm profile image
Sripalinm

Could you please let us know what is the "keyConfig" (in app.js) , and how should be the content there,

Collapse
raysercast1 profile image
raysercast1

Hi Sripalinm. Could you find out what does keyConfig do and the content in it ?

Collapse
miteshkamat27 profile image
Mitesh Kamat Author

Hi There,
Sorry for late response. KeyConfig is nothing but an object which consists of secret key, token signing algorithm details, etc.

Like:

module.exports = {
    secretKey: 'a@b!#key',
   tokenAlgo: 'aes-128-cbc'
...
}
Collapse
bart96b profile image
BART96

«lodash»? Realy? 2020 year!