DEV Community

Mitesh Kamat
Mitesh Kamat

Posted on • Updated on

SSO + Express JS + Passport-saml

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 
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
  };
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' }));
Enter fullscreen mode Exit fullscreen mode

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');
  }
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

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

Top comments (19)

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

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 • 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
 
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

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
 
goyaldeeksha profile image
goyal-deeksha

Hi Mitesh,
I am new to SSO. I am creating micro services like users.abc.com, goals.abc.com etc... with separate backend, frontend and database for each.
I have to create SSO so that I will be able to login in all systems with single credentials.
Will you please tell me, passport-saml strategy is the right choice for that and if yes, then how can I achieve that.
And you have something different, then please let me know.

Thanks in advance.

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

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

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

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

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!