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:
- app.js - This holds my server side code which is using express js
- config.js - login/ logout configuration
- provider.xml - It holds xml metadata (SP and IDP details)
- reader.js - Reading metadata from xml
- private.pem - Certificate for authentication
- 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:
- Store the SAML response after authentication.
- Decode the response and convert it into XML.
- Extract token that you might receive and store it for future use.
- If you are creating a token, then use jsonwebtoken that is available as npm package to sign/verify/encode/decode.
- While signing a token you can send the desired payload that might include credentials, secret key, etc.
- 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 !!!
Top comments (19)
Hello Mitesh, great article
do you have a github repo for all the code?
thanks
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.
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
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.
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
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.
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.
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!
Hi Mitesh, Really Thanks for the quick and kind response, and If its possible, then it will be the greatest help for me.
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.
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;
Can you try adding a middleware for router.post('/SSO', authMiddleware);
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.
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"...
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.
Could you please let us know what is the "keyConfig" (in app.js) , and how should be the content there,
Hi Sripalinm. Could you find out what does keyConfig do and the content in it ?
Hi There,
Sorry for late response. KeyConfig is nothing but an object which consists of secret key, token signing algorithm details, etc.
Like:
«lodash»? Realy? 2020 year!