SSO + Express JS + Passport-saml
Tags: sso, express, passport, passportsaml
Published: 2020-02-10 08:01:34

####Introduction
In my [previous post](https://dev.to/miteshkamat27/parsing-saml-namespace-prefixes-59dl), 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
```javascript
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.
```javascript
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
```javascript
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:
```javascript
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
```javascript
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.
```javascript
app.get('/',passport.authenticate('saml', { 'successRedirect': '/', 'failureRedirect': '/login' }));
```
It specifies that we will use saml strategy for authentication.
Next route after authentication.
```javascript
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)
```javascript
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 !!!