UNPKG

saml-idp

Version:

Test Identity Provider (IdP) for SAML 2.0 Web Browser SSO Profile

840 lines (758 loc) 26.3 kB
/** * Module dependencies. */ const chalk = require('chalk'), express = require('express'), os = require('os'), fs = require('fs'), http = require('http'), https = require('https'), path = require('path'), extend = require('extend'), hbs = require('hbs'), logger = require('morgan'), bodyParser = require('body-parser'), session = require('express-session'), yargs = require('yargs/yargs'), xmlFormat = require('xml-formatter'), samlp = require('samlp'), Parser = require('xmldom').DOMParser, SessionParticipants = require('samlp/lib/sessionParticipants'), SimpleProfileMapper = require('./lib/simpleProfileMapper.js'); /** * Globals */ const IDP_PATHS = { SSO: '/saml/sso', SLO: '/saml/slo', METADATA: '/metadata', SIGN_IN: '/signin', SIGN_OUT: '/signout', SETTINGS: '/settings' } const CERT_OPTIONS = [ 'cert', 'key', 'encryptionCert', 'encryptionPublicKey', 'httpsPrivateKey', 'httpsCert', ]; const WILDCARD_ADDRESSES = ['0.0.0.0', '::']; const UNDEFINED_VALUE = 'None'; const CRYPT_TYPES = { certificate: /-----BEGIN CERTIFICATE-----[^-]*-----END CERTIFICATE-----/, 'RSA private key': /-----BEGIN RSA PRIVATE KEY-----\n[^-]*\n-----END RSA PRIVATE KEY-----/, 'public key': /-----BEGIN PUBLIC KEY-----\n[^-]*\n-----END PUBLIC KEY-----/, }; const KEY_CERT_HELP_TEXT = dedent(chalk` To generate a key/cert pair for the IdP, run the following command: {gray openssl req -x509 -new -newkey rsa:2048 -nodes \ -subj '/C=US/ST=California/L=San Francisco/O=JankyCo/CN=Test Identity Provider' \ -keyout idp-private-key.pem \ -out idp-public-cert.pem -days 7300}` ); function matchesCertType(value, type) { return CRYPT_TYPES[type] && CRYPT_TYPES[type].test(value); } function resolveFilePath(filePath) { if (filePath.startsWith('saml-idp/')) { // Allows file path options to files included in this package, like config.js const resolvedPath = require.resolve(filePath.replace(/^saml\-idp\//, `${__dirname}/`)); return fs.existsSync(resolvedPath) && resolvedPath; } var possiblePath; if (fs.existsSync(filePath)) { return filePath; } if (filePath.startsWith('~/')) { possiblePath = path.resolve(process.env.HOME, filePath.slice(2)); if (fs.existsSync(possiblePath)) { return possiblePath; } else { // for ~/ paths, don't try to resolve further return filePath; } } return ['.', __dirname] .map(base => path.resolve(base, filePath)) .find(possiblePath => fs.existsSync(possiblePath)); } function makeCertFileCoercer(type, description, helpText) { return function certFileCoercer(value) { if (matchesCertType(value, type)) { return value; } const filePath = resolveFilePath(value); if (filePath) { return fs.readFileSync(filePath) } throw new Error( chalk`{red Invalid / missing {bold ${description}}} - {yellow not a valid crypt key/cert or file path}${helpText ? '\n' + helpText : ''}` ) }; } function getHashCode(str) { var hash = 0; if (str.length == 0) return hash; for (i = 0; i < str.length; i++) { char = str.charCodeAt(i); hash = ((hash<<5)-hash)+char; hash = hash & hash; // Convert to 32bit integer } return hash; } function dedent(str) { // Reduce the indentation of all lines by the indentation of the first line const match = str.match(/^\n?( +)/); if (!match) { return str; } const indentRe = new RegExp(`\n${match[1]}`, 'g'); return str.replace(indentRe, '\n').replace(/^\n/, ''); } function formatOptionValue(key, value) { if (typeof value === 'string') { return value; } if (CERT_OPTIONS.includes(key)) { return chalk`${ value.toString() .replace(/-----.+?-----|\n/g, '') .substring(0, 80) }{white …}`; } if (!value && value !== false) { return UNDEFINED_VALUE; } if (typeof value === 'function') { const lines = `${value}`.split('\n'); return lines[0].slice(0, -2); } return `${JSON.stringify(value)}`; } function prettyPrintXml(xml, indent) { // This works well, because we format the xml before applying the replacements const prettyXml = xmlFormat(xml, {indentation: ' '}) // Matches `<{prefix}:{name} .*?>` .replace(/<(\/)?((?:[\w]+)(?::))?([\w]+)(.*?)>/g, chalk`<{green $1$2{bold $3}}$4>`) // Matches ` {attribute}="{value}" .replace(/ ([\w:]+)="(.+?)"/g, chalk` {white $1}={cyan "$2"}`); if (indent) { return prettyXml.replace(/(^|\n)/g, `$1${' '.repeat(indent)}`); } return prettyXml; } /** * Arguments */ function processArgs(args, options) { var baseArgv; if (options) { baseArgv = yargs(args).config(options); } else { baseArgv = yargs(args); } return baseArgv .usage('\nSimple IdP for SAML 2.0 WebSSO & SLO Profile\n\n' + 'Launches an IdP web server that mints SAML assertions or logout responses for a Service Provider (SP)\n\n' + 'Usage:\n\t$0 --acsUrl {url} --audience {uri}') .alias({h: 'help'}) .options({ host: { description: 'IdP Web Server Listener Host', required: false, default: 'localhost' }, port: { description: 'IdP Web Server Listener Port', required: true, alias: 'p', default: 7000 }, cert: { description: 'IdP Signature PublicKey Certificate', required: true, default: './idp-public-cert.pem', coerce: makeCertFileCoercer('certificate', 'IdP Signature PublicKey Certificate', KEY_CERT_HELP_TEXT) }, key: { description: 'IdP Signature PrivateKey Certificate', required: true, default: './idp-private-key.pem', coerce: makeCertFileCoercer('RSA private key', 'IdP Signature PrivateKey Certificate', KEY_CERT_HELP_TEXT) }, issuer: { description: 'IdP Issuer URI', required: true, alias: 'iss', default: 'urn:example:idp' }, acsUrl: { description: 'SP Assertion Consumer URL', required: true, alias: 'acs' }, sloUrl: { description: 'SP Single Logout URL', required: false, alias: 'slo' }, audience: { description: 'SP Audience URI', required: true, alias: 'aud' }, serviceProviderId: { description: 'SP Issuer/Entity URI', required: false, alias: 'spId', string: true }, relayState: { description: 'Default SAML RelayState for SAMLResponse', required: false, alias: 'rs' }, disableRequestAcsUrl: { description: 'Disables ability for SP AuthnRequest to specify Assertion Consumer URL', required: false, boolean: true, alias: 'static', default: false }, encryptAssertion: { description: 'Encrypts assertion with SP Public Key', required: false, boolean: true, alias: 'enc', default: false }, encryptionCert: { description: 'SP Certificate (pem) for Assertion Encryption', required: false, string: true, alias: 'encCert', coerce: makeCertFileCoercer('certificate', 'Encryption cert') }, encryptionPublicKey: { description: 'SP RSA Public Key (pem) for Assertion Encryption ' + '(e.g. openssl x509 -pubkey -noout -in sp-cert.pem)', required: false, string: true, alias: 'encKey', coerce: makeCertFileCoercer('public key', 'Encryption public key') }, httpsPrivateKey: { description: 'Web Server TLS/SSL Private Key (pem)', required: false, string: true, coerce: makeCertFileCoercer('RSA private key') }, httpsCert: { description: 'Web Server TLS/SSL Certificate (pem)', required: false, string: true, coerce: makeCertFileCoercer('certificate') }, https: { description: 'Enables HTTPS Listener (requires httpsPrivateKey and httpsCert)', required: true, boolean: true, default: false }, signResponse: { description: 'Enables signing of responses', required: false, boolean: true, default: true, alias: 'signResponse' }, configFile: { description: 'Path to a SAML attribute config file', required: true, default: 'saml-idp/config.js', alias: 'conf' }, rollSession: { description: 'Create a new session for every authn request instead of reusing an existing session', required: false, boolean: true, default: false }, authnContextClassRef: { description: 'Authentication Context Class Reference', required: false, string: true, default: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', alias: 'acr' }, authnContextDecl: { description: 'Authentication Context Declaration (XML FilePath)', required: false, string: true, alias: 'acd', coerce: function (value) { const filePath = resolveFilePath(value); if (filePath) { return fs.readFileSync(filePath, 'utf8') } } } }) .example('$0 --acsUrl http://acme.okta.com/auth/saml20/exampleidp --audience https://www.okta.com/saml2/service-provider/spf5aFRRXFGIMAYXQPNV', '') .check(function(argv, aliases) { if (argv.encryptAssertion) { if (argv.encryptionPublicKey === undefined) { return 'encryptionPublicKey argument is also required for assertion encryption'; } if (argv.encryptionCert === undefined) { return 'encryptionCert argument is also required for assertion encryption'; } } return true; }) .check(function(argv, aliases) { if (argv.config) { return true; } const configFilePath = resolveFilePath(argv.configFile); if (!configFilePath) { return 'SAML attribute config file path "' + argv.configFile + '" is not a valid path.\n'; } try { argv.config = require(configFilePath); } catch (error) { return 'Encountered an exception while loading SAML attribute config file "' + configFilePath + '".\n' + error; } return true; }) .wrap(baseArgv.terminalWidth()); } function _runServer(argv) { const app = express(); const httpServer = argv.https ? https.createServer({ key: argv.httpsPrivateKey, cert: argv.httpsCert }, app) : http.createServer(app); const blocks = {}; console.log(dedent(chalk` Listener Port: {cyan ${argv.host}:${argv.port}} HTTPS Enabled: {cyan ${argv.https}} {bold [{yellow Identity Provider}]} Issuer URI: {cyan ${argv.issuer}} Sign Response Message: {cyan ${argv.signResponse}} Encrypt Assertion: {cyan ${argv.encryptAssertion}} Authentication Context Class Reference: {cyan ${argv.authnContextClassRef || UNDEFINED_VALUE}} Authentication Context Declaration: {cyan ${argv.authnContextDecl || UNDEFINED_VALUE}} Default RelayState: {cyan ${argv.relayState || UNDEFINED_VALUE}} {bold [{yellow Service Provider}]} Issuer URI: {cyan ${argv.serviceProviderId || UNDEFINED_VALUE}} Audience URI: {cyan ${argv.audience || UNDEFINED_VALUE}} ACS URL: {cyan ${argv.acsUrl || UNDEFINED_VALUE}} SLO URL: {cyan ${argv.sloUrl || UNDEFINED_VALUE}} Trust ACS URL in Request: {cyan ${!argv.disableRequestAcsUrl}} `)); /** * IdP Configuration */ const idpOptions = { issuer: argv.issuer, serviceProviderId: argv.serviceProviderId || argv.audience, cert: argv.cert, key: argv.key, audience: argv.audience, recipient: argv.acsUrl, destination: argv.acsUrl, acsUrl: argv.acsUrl, sloUrl: argv.sloUrl, RelayState: argv.relayState, allowRequestAcsUrl: !argv.disableRequestAcsUrl, digestAlgorithm: 'sha256', signatureAlgorithm: 'rsa-sha256', signResponse: argv.signResponse, encryptAssertion: argv.encryptAssertion, encryptionCert: argv.encryptionCert, encryptionPublicKey: argv.encryptionPublicKey, encryptionAlgorithm: 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', keyEncryptionAlgorithm: 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p', lifetimeInSeconds: 3600, authnContextClassRef: argv.authnContextClassRef, authnContextDecl: argv.authnContextDecl, includeAttributeNameFormat: true, profileMapper: SimpleProfileMapper.fromMetadata(argv.config.metadata), postEndpointPath: IDP_PATHS.SSO, redirectEndpointPath: IDP_PATHS.SSO, logoutEndpointPaths: argv.sloUrl ? { redirect: IDP_PATHS.SLO, post: IDP_PATHS.SLO } : {}, getUserFromRequest: function(req) { return req.user; }, getPostURL: function (audience, authnRequestDom, req, callback) { return callback(null, (req.authnRequest && req.authnRequest.acsUrl) ? req.authnRequest.acsUrl : req.idp.options.acsUrl); }, transformAssertion: function(assertionDom) { if (argv.authnContextDecl) { var declDoc; try { declDoc = new Parser().parseFromString(argv.authnContextDecl); } catch(err){ console.log('Unable to parse Authentication Context Declaration XML', err); } if (declDoc) { const authnContextDeclEl = assertionDom.createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AuthnContextDecl'); authnContextDeclEl.appendChild(declDoc.documentElement); const authnContextEl = assertionDom.getElementsByTagName('saml:AuthnContext')[0]; authnContextEl.appendChild(authnContextDeclEl); } } }, responseHandler: function(response, opts, req, res, next) { console.log(dedent(chalk` Sending SAML Response to {cyan ${opts.postUrl}} => {bold RelayState} => {cyan ${opts.RelayState || UNDEFINED_VALUE}} {bold SAMLResponse} =>` )); console.log(prettyPrintXml(response.toString(), 4)); res.render('samlresponse', { AcsUrl: opts.postUrl, SAMLResponse: response.toString('base64'), RelayState: opts.RelayState }); } } /** * App Environment */ app.set('host', process.env.HOST || argv.host); app.set('port', process.env.PORT || argv.port); app.set('views', path.join(__dirname, 'views')); /** * View Engine */ app.set('view engine', 'hbs'); app.set('view options', { layout: 'layout' }) app.engine('handlebars', hbs.__express); // Register Helpers hbs.registerHelper('extend', function(name, context) { var block = blocks[name]; if (!block) { block = blocks[name] = []; } block.push(context.fn(this)); }); hbs.registerHelper('block', function(name) { const val = (blocks[name] || []).join('\n'); // clear the block blocks[name] = []; return val; }); hbs.registerHelper('select', function(selected, options) { return options.fn(this).replace( new RegExp(' value=\"' + selected + '\"'), '$& selected="selected"'); }); hbs.registerHelper('getProperty', function(attribute, context) { return context[attribute]; }); hbs.registerHelper('serialize', function(context) { return new Buffer(JSON.stringify(context)).toString('base64'); }); /** * Middleware */ app.use(logger(':date> :method :url - {:referrer} => :status (:response-time ms)', { skip: function (req, res) { return req.path.startsWith('/bower_components') || req.path.startsWith('/css') } })); app.use(bodyParser.urlencoded({extended: true})); app.use(express.static(path.join(__dirname, 'public'))); app.use(session({ secret: 'The universe works on a math equation that never even ever really ends in the end', resave: false, saveUninitialized: true, name: 'idp_sid', cookie: { maxAge: 60 * 60 * 1000 } })); /** * View Handlers */ const showUser = function (req, res, next) { res.render('user', { user: req.user, participant: req.participant, metadata: req.metadata, authnRequest: req.authnRequest, idp: req.idp.options, paths: IDP_PATHS }); } /** * Shared Handlers */ const parseSamlRequest = function(req, res, next) { samlp.parseRequest(req, function(err, data) { if (err) { return res.render('error', { message: 'SAML AuthnRequest Parse Error: ' + err.message, error: err }); }; if (data) { req.authnRequest = { relayState: req.query.RelayState || req.body.RelayState, id: data.id, issuer: data.issuer, destination: data.destination, acsUrl: data.assertionConsumerServiceURL, forceAuthn: data.forceAuthn === 'true' }; console.log('Received AuthnRequest => \n', req.authnRequest); } return showUser(req, res, next); }) }; const getSessionIndex = function(req) { if (req && req.session) { return Math.abs(getHashCode(req.session.id)).toString(); } } const getParticipant = function(req) { return { serviceProviderId: req.idp.options.serviceProviderId, sessionIndex: getSessionIndex(req), nameId: req.user.userName, nameIdFormat: req.user.nameIdFormat, serviceProviderLogoutURL: req.idp.options.sloUrl } } const parseLogoutRequest = function(req, res, next) { if (!req.idp.options.sloUrl) { return res.render('error', { message: 'SAML Single Logout Service URL not defined for Service Provider' }); }; console.log('Processing SAML SLO request for participant => \n', req.participant); return samlp.logout({ issuer: req.idp.options.issuer, cert: req.idp.options.cert, key: req.idp.options.key, digestAlgorithm: req.idp.options.digestAlgorithm, signatureAlgorithm: req.idp.options.signatureAlgorithm, sessionParticipants: new SessionParticipants( [ req.participant ]), clearIdPSession: function(callback) { console.log('Destroying session ' + req.session.id + ' for participant', req.participant); req.session.destroy(); callback(); } })(req, res, next); } /** * Routes */ app.use(function(req, res, next){ if (argv.rollSession) { req.session.regenerate(function(err) { return next(); }); } else { next() } }); app.use(function(req, res, next){ req.user = argv.config.user; req.metadata = argv.config.metadata; req.idp = { options: idpOptions }; req.participant = getParticipant(req); next(); }); app.get(['/', '/idp', IDP_PATHS.SSO], parseSamlRequest); app.post(['/', '/idp', IDP_PATHS.SSO], parseSamlRequest); app.get(IDP_PATHS.SLO, parseLogoutRequest); app.post(IDP_PATHS.SLO, parseLogoutRequest); app.post(IDP_PATHS.SIGN_IN, function(req, res) { const authOptions = extend({}, req.idp.options); Object.keys(req.body).forEach(function(key) { var buffer; if (key === '_authnRequest') { buffer = new Buffer(req.body[key], 'base64'); req.authnRequest = JSON.parse(buffer.toString('utf8')); // Apply AuthnRequest Params authOptions.inResponseTo = req.authnRequest.id; if (req.idp.options.allowRequestAcsUrl && req.authnRequest.acsUrl) { authOptions.acsUrl = req.authnRequest.acsUrl; authOptions.recipient = req.authnRequest.acsUrl; authOptions.destination = req.authnRequest.acsUrl; authOptions.forceAuthn = req.authnRequest.forceAuthn; } if (req.authnRequest.relayState) { authOptions.RelayState = req.authnRequest.relayState; } } else { req.user[key] = req.body[key]; } }); if (!authOptions.encryptAssertion) { delete authOptions.encryptionCert; delete authOptions.encryptionPublicKey; } // Set Session Index authOptions.sessionIndex = getSessionIndex(req); // Keep calm and Single Sign On console.log(dedent(chalk` Generating SAML Response using => {bold User} => ${Object.entries(req.user).map(([key, value]) => chalk` ${key}: {cyan ${value}}` ).join('')} {bold SAMLP Options} => ${Object.entries(authOptions).map(([key, value]) => chalk` ${key}: {cyan ${formatOptionValue(key, value)}}` ).join('')} `)); samlp.auth(authOptions)(req, res); }) app.get(IDP_PATHS.METADATA, function(req, res, next) { samlp.metadata(req.idp.options)(req, res); }); app.post(IDP_PATHS.METADATA, function(req, res, next) { if (req.body && req.body.attributeName && req.body.displayName) { var attributeExists = false; const attribute = { id: req.body.attributeName, optional: true, displayName: req.body.displayName, description: req.body.description || '', multiValue: req.body.valueType === 'multi' }; req.metadata.forEach(function(entry) { if (entry.id === req.body.attributeName) { entry = attribute; attributeExists = true; } }); if (!attributeExists) { req.metadata.push(attribute); } console.log("Updated SAML Attribute Metadata => \n", req.metadata) res.status(200).end(); } }); app.get(IDP_PATHS.SIGN_OUT, function(req, res, next) { if (req.idp.options.sloUrl) { console.log('Initiating SAML SLO request for user: ' + req.user.userName + ' with sessionIndex: ' + getSessionIndex(req)); res.redirect(IDP_PATHS.SLO); } else { console.log('SAML SLO is not enabled for SP, destroying IDP session'); req.session.destroy(function(err) { if (err) { throw err; } res.redirect('back'); }) } }); app.get([IDP_PATHS.SETTINGS], function(req, res, next) { res.render('settings', { idp: req.idp.options }); }); app.post([IDP_PATHS.SETTINGS], function(req, res, next) { Object.keys(req.body).forEach(function(key) { switch(req.body[key].toLowerCase()){ case "true": case "yes": case "1": req.idp.options[key] = true; break; case "false": case "no": case "0": req.idp.options[key] = false; break; default: req.idp.options[key] = req.body[key]; break; } if (req.body[key].match(/^\d+$/)) { req.idp.options[key] = parseInt(req.body[key], '10'); } }); console.log('Updated IdP Configuration => \n', req.idp.options); res.redirect('/'); }); // catch 404 and forward to error handler app.use(function(req, res, next) { const err = new Error('Route Not Found'); err.status = 404; next(err); }); // development error handler app.use(function(err, req, res, next) { if (err) { res.status(err.status || 500); res.render('error', { message: err.message, error: err }); } }); /** * Start IdP Web Server */ console.log(chalk`Starting IdP server on port {cyan ${app.get('host')}:${app.get('port')}}...\n`); httpServer.listen(app.get('port'), app.get('host'), function() { const scheme = argv.https ? 'https' : 'http', {address, port} = httpServer.address(), hostname = WILDCARD_ADDRESSES.includes(address) ? os.hostname() : 'localhost', baseUrl = `${scheme}://${hostname}:${port}`; console.log(dedent(chalk` IdP Metadata URL: {cyan ${baseUrl}${IDP_PATHS.METADATA}} SSO Bindings: urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST => {cyan ${baseUrl}${IDP_PATHS.SSO}} urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect => {cyan ${baseUrl}${IDP_PATHS.SSO}} ${argv.sloUrl ? ` SLO Bindings: urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST => {cyan ${baseUrl}${IDP_PATHS.SLO}} urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect => {cyan ${baseUrl}${IDP_PATHS.SLO}} ` : ''} IdP server ready at {cyan ${baseUrl}} `)); }); } function runServer(options) { const args = processArgs([], options); return _runServer(args.argv); } function main () { const args = processArgs(process.argv.slice(2)); _runServer(args.argv); } module.exports = { runServer, main, }; if (require.main === module) { main(); }