var fs = require('fs'), url = require('url'), zlib = require('zlib'), querystring = require('querystring'), Promise = require('bluebird'), log4js = require('log4js'), // the logger express = require('express'), xmlCrypto = require('xml-crypto'), xmldom = require('xmldom'), xmlbuilder = require('xmlbuilder'), crypto = require('crypto'), bodyParser = require('body-parser'), http_app = express(), http_server = require('http').createServer(http_app); http_app.use(bodyParser.urlencoded({extended: true})); http_app.use(bodyParser.json()); http_server.listen(8085, 'localhost'); // ================================================================================ // The logger // ================================================================================ log4js.loadAppender('file'); log4js.addAppender(log4js.appenders.file(process.env.CODEQ_SAML_LOG || 'codeq-saml.log'), 'log'); var logger = log4js.getLogger('log'); var logException = function (msg, error) { if ((error instanceof Error) && (typeof error.stack === 'string')) msg += '\n' + error.stack; logger.error(msg); }; // prevent the node from exiting because of an unhandled exception Promise.onPossiblyUnhandledRejection(function (error) { logException('Unhandled promise rejection: ' + error, error); }); process.on('uncaughtException', function (error) { logException('Uncaught exception: ' + error, error); }); // ================================================================================ // Misc // ================================================================================ var assertionConsumerServiceURL = 'https://codeq.si/Shibboleth.sso/SAML2/POST', SPIssuer = 'https://codeq.si/sp/201509281041', privateCert = fs.readFileSync('/etc/shibboleth/sp-key.pem', 'utf8'), SamlStatusSuccess = 'urn:oasis:names:tc:SAML:2.0:status:Success'; // IdP: idp.aai.arnes.si var SSOServiceURL = 'https://idp.aai.arnes.si/simplesaml/saml2/idp/SSOService.php', SLOServiceURL = 'https://idp.aai.arnes.si/simplesaml/saml2/idp/SingleLogoutService.php', IdPIssuer = 'https://idp.aai.arnes.si/idp/20090116', IdPKeyInfoProvider = new xmlCrypto.FileKeyInfo('/etc/shibboleth/idp.crt'); /* // IdP: idp.uni-lj.si var SSOServiceURL = 'https://idp.uni-lj.si/simplesaml/saml2/idp/SSOService.php', SLOServiceURL = 'https://idp.uni-lj.si/simplesaml/saml2/idp/SingleLogoutService.php', IdPIssuer = 'https://idp.uni-lj.si/idp/20100525', IdPKeyInfoProvider = new xmlCrypto.FileKeyInfo('/etc/shibboleth/idp.uni-lj.si.crt'); */ var idChars = '0123456789abcdef', makeRequestId = function () { var idParts = [], i; for (i = 0; i < 20; i++) idParts.push(idChars[Math.floor(Math.random() * 15)]); return idParts.join(''); }; var waitingLogins = {}, // maps sid to waiter waitingLogouts = {}, parkedLoginRequests = {}, // maps SAML request ID to waiter parkedLogoutRequests = {}; var makeSamlRedirectUrl = function (serviceUrl, queryKey, queryObj, additionalData, callback) { var xml = xmlbuilder.create(queryObj).end(); logger.debug('Redirecting with ' + queryKey + ' to ' + serviceUrl + ':\n' + xml); zlib.deflateRaw(xml, function (err, buffer) { var svcUrl, signer, query, keys, i, key; if (err) { callback(err, null); return; } query = { 'SigAlg': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' }; query[queryKey] = buffer.toString('base64'); if (additionalData && (typeof additionalData === 'object')) { keys = Object.keys(additionalData); for (i = keys.length - 1; i >= 0; i--) { key = keys[i]; query[key] = additionalData[key]; } } signer = crypto.createSign('RSA-SHA1'); signer.update(querystring.stringify(query)); query.Signature = signer.sign(privateCert, 'base64'); svcUrl = url.parse(serviceUrl); svcUrl.query = query; delete svcUrl.search; callback(null, url.format(svcUrl)); }); }; var makeWaiter = function (sid) { var waiter = { 'sid': sid, 'created': Date.now(), 'listeners': 0 }; waiter.promise = new Promise(function (resolve, reject) { waiter.resolve = resolve; waiter.reject = reject; }); return waiter; }; // ================================================================================ // XML DOM data extraction functions for SAML data // ================================================================================ var validateXmlSignature = function (xml, signatureNode) { var sig = new xmlCrypto.SignedXml(); sig.keyInfoProvider = IdPKeyInfoProvider; sig.loadSignature(signatureNode.toString()); return sig.checkSignature(xml); }; var xmlStatusOk = function (statusNode) { var node = statusNode.firstChild, attributes, i; while (node) { if (node.localName === 'StatusCode') { attributes = node.attributes; for (i = attributes.length - 1; i >= 0; i--) { if (attributes[i].localName === 'Value') return attributes[i].value == SamlStatusSuccess; } } node = node.nextSibling; } return null; }; var parseAttributeValue = function (attributeNode) { var node = attributeNode.firstChild; while (node) { if (node.localName === 'AttributeValue') { return node.firstChild.data; } node = node.nextSibling; } return null; }; var parseAttributes = function (attributeStatementNode) { var node = attributeStatementNode.firstChild, result = {}, attributes, i, name; while (node) { if (node.localName === 'Attribute') { attributes = node.attributes; for (i = attributes.length - 1; i >= 0; i--) { if (attributes[i].localName === 'Name') { name = attributes[i].value; break; } } if (i < 0) { console.log('ERROR: an attribute without a name'); } else { result[name] = parseAttributeValue(node); } } node = node.nextSibling; } return result; }; // return the NameID in the format for use with xmlbuilder var parseNameID = function (NameIDNode) { var result = {}, i, attributes; result['#text'] = NameIDNode.firstChild.data; attributes = NameIDNode.attributes; for (i = attributes.length - 1; i >= 0; i--) { switch (attributes[i].localName) { case 'NameQualifier': // NameID namespace result['@NameQualifier'] = attributes[i].value; break; case 'SPNameQualifier': // 2nd level NameID namespace result['@SPNameQualifier'] = attributes[i].value; break; case 'Format': result['@Format'] = attributes[i].value; break; case 'SPProvidedID': result['@SPProvidedID'] = attributes[i].value; break; } } return result; }; var parseSubject = function (subjectNode) { var node = subjectNode.firstChild, result = {}; while (node) { if (node.localName === 'NameID') { result['NameID'] = parseNameID(node); } node = node.nextSibling; } return result; }; var parseAssertion = function (xml, assertionNode) { var node = assertionNode.firstChild, result = {}, attributes, i; while (node) { switch (node.localName) { case 'Signature': result.signatureOk = validateXmlSignature(xml, node); break; case 'Subject': // extract presenter identifier result.subject = parseSubject(node); break; case 'AuthnStatement': // extract the session index attributes = node.attributes; for (i = attributes.length - 1; i >= 0; i--) { if (attributes[i].localName === 'SessionIndex') { result.SessionIndex = attributes[i].value; break; } } break; case 'AttributeStatement': result.attributes = parseAttributes(node); break; } node = node.nextSibling; } return result; }; // ================================================================================ // SAML logout handlers // ================================================================================ // process response to our (global) logout request var handleLogoutResponse = function (xml, form, res) { var errors = [], issuer = null, status = null, session = null, doc, rootNode, attributes, i, inResponseTo, node; doc = new xmldom.DOMParser().parseFromString(xml); rootNode = doc.documentElement; if (rootNode.localName != 'LogoutResponse') { errors.push('not a LogoutResponse: ' + rootNode.localName); } else { attributes = rootNode.attributes; for (i = attributes.length - 1; i >= 0; i--) { if (attributes[i].localName === 'InResponseTo') { inResponseTo = attributes[i].value; break; } } if (!inResponseTo) errors.push('request ID that this message is a response to is missing'); else { session = waitingLogouts[inResponseTo]; if (!session) errors.push('there is no session waiting on request ID ' + inResponseTo); } node = rootNode.firstChild; while (node) { switch (node.localName) { case 'Issuer': issuer = node.firstChild.data; break; case 'Status': status = xmlStatusOk(node); break; default: // errors.push('Unknown/unsupported top XML node: ' + node.localName + ', prefix: ' + node.prefix); break; } node = node.nextSibling; } if (issuer === null) errors.push('Issuer is not set'); else if (issuer !== IdPIssuer) errors.push('Invalid IdP issuer: ' + issuer); if (status === null) errors.push('response status is not set'); else if (!status) errors.push('response status is not OK'); } res.set('Content-Type', 'text/plain'); if (errors.length > 0) { if (errors.length > 1) res.send('Multiple errors:\n ' + errors.join('\n ')); else res.send('Error: ' + errors[0]); session.samlLogout(errors.join('\n')); } else { res.send('OK'); session.samlLogout(null); } }; // a third party initiated the (global) logout, process the request and redirect with a response var handleLogoutRequest = function (xml, form, res) { var errors = [], nameId = null, sessionIndex = null, doc, rootNode, attributes, i, requestId, node, responseObj, responseId; doc = new xmldom.DOMParser().parseFromString(xml); rootNode = doc.documentElement; if (rootNode.localName != 'LogoutRequest') { errors.push('not a LogoutRequest: ' + rootNode.localName); } else { attributes = rootNode.attributes; for (i = attributes.length - 1; i >= 0; i--) { if (attributes[i].localName === 'ID') { requestId = attributes[i].value; break; } } if (!requestId) errors.push('request ID is missing'); node = rootNode.firstChild; while (node) { switch (node.localName) { case 'Issuer': if (node.firstChild.data !== IdPIssuer) errors.push('ERROR: invalid IdP issuer: ' + node.firstChild.data); break; case 'NameID': nameId = parseNameID(node); break; case 'SessionIndex': sessionIndex = node.firstChild && node.firstChild.data; break; } node = node.nextSibling; } if (!nameId) errors.push('NameID not present'); else { if (!nameId['#text']) errors.push('NameID is empty'); } } if (errors.length > 0) { res.set('Content-Type', 'text/plain'); res.send(errors.join('\n')); } else { responseId = makeRequestId(); responseObj = { 'samlp:LogoutResponse': { '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '@Version': '2.0', '@Destination': SLOServiceURL, '@ID': responseId, '@IssueInstant': new Date().toISOString(), '@InResponseTo': requestId, 'saml:Issuer' : { '#text': SPIssuer }, 'samlp:Status': { 'samlp:StatusCode': { '@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success' } } } }; makeSamlRedirectUrl(SLOServiceURL, 'SAMLRequest', responseObj, form.RelayState ? {RelayState: form.RelayState} : null, function (err, svcUrl) { if (err) { console.log('Error while assembling the Logout SAML response: ' + err); res.send('Failed: ' + err); return; } res.redirect(svcUrl); // redirect to the IdP }); } }; // ================================================================================ // SAML web request handlers // ================================================================================ // redirect to login on Arnes AAI, https://codeq.si/Shibboleth.sso/Login http_app.get('/Shibboleth.sso/Login', function (req, res) { var sid = req.query.sid, waiter, requestId, request; try { if (!sid) { res.send('SID not set'); return; } waiter = waitingLogins[sid]; if (!waiter) waitingLogins[sid] = waiter = makeWaiter(sid); requestId = makeRequestId(); request = { 'samlp:AuthnRequest': { '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '@Version': '2.0', '@ProtocolBinding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', '@AssertionConsumerServiceURL': assertionConsumerServiceURL, '@Destination': SSOServiceURL, '@ID': requestId, '@IssueInstant': new Date().toISOString(), 'saml:Issuer': { '#text': SPIssuer } } }; makeSamlRedirectUrl(SSOServiceURL, 'SAMLRequest', request, null, function (err, svcUrl) { if (err) { logException('Error while assembling the Login SAML request: ' + err, err); res.send('Failed: ' + err); waiter.done = Date.now(); waiter.reject(err instanceof Error ? err : new Error(err)); return; } res.redirect(svcUrl); // redirect to the IdP parkedLoginRequests[requestId] = waiter; waiter.parked = Date.now(); waiter.requestId = requestId; }); } catch (e) { logException('Exception while processing login request: ' + e, e); res.send('Error: ' + e); if (waiter) { waiter.done = Date.now(); waiter.reject(e); } } }); // redirect to logout on Arnes AAI, https://codeq.si/Shibboleth.sso/Logout http_app.get('/Shibboleth.sso/Logout', function (req, res) { var sid = req.query.sid, session, samlData, requestId, request; if (!sid) { res.send('SID not set'); return; } session = sessions[sid]; if (!session) { res.send('No such session: ' + sid); return; } samlData = session.saml; if (!samlData) { res.send('No SAML session associated with session ' + sid); return; } if (!samlData.NameID) { res.send('The SAML data for session ' + sid + ' does not contain the NameID'); return; } requestId = makeRequestId(); request = { 'samlp:LogoutRequest': { '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '@Version': '2.0', '@Destination': SLOServiceURL, '@ID': requestId, '@IssueInstant': new Date().toISOString(), 'saml:Issuer' : { '#text': SPIssuer }, 'saml:NameID': samlData.NameID } }; if (samlData.SessionIndex) requestObj['samlp:LogoutRequest']['samlp:SessionIndex'] = {'#text': samlData.SessionIndex}; makeSamlRedirectUrl(SLOServiceURL, 'SAMLRequest', request, null, function (err, svcUrl) { if (err) { console.log('Error while assembling the Logout SAML request: ' + err); res.send('Failed: ' + err); return; } res.redirect(svcUrl); // redirect to the IdP parkedLogoutRequests[requestId] = session; }); }); // user logged in, read the credentials http_app.post('/Shibboleth.sso/SAML2/POST', function (req, res) { var form = req.body, keys = [], errors = [], inResponseTo = null, issuer = null, signature = null, status = null, assertion = null, waiter = null, attributes, i, key, value, xml, doc, rootNode, node, NameID, Format, SPNameQualifier, SessionIndex, samlData, userData; value = form.SAMLResponse; if (!value) { errors.push('no SAMLResponse'); } else { xml = new Buffer(value, 'base64').toString('utf8'); doc = new xmldom.DOMParser().parseFromString(xml); rootNode = doc.documentElement; if (rootNode.localName != 'Response') { errors.push('the root XML node is not a Response: ' + rootNode.localName); } else { attributes = rootNode.attributes; for (i = attributes.length - 1; i >= 0; i--) { if (attributes[i].localName === 'InResponseTo') { inResponseTo = attributes[i].value; break; } } if (!inResponseTo) errors.push('request ID that this message is a response to is missing'); else { waiter = parkedLoginRequests[inResponseTo]; if (!waiter) errors.push('there is no session waiting on request ID ' + inResponseTo); else { waiter.done = Date.now(); delete parkedLoginRequests[inResponseTo]; } node = rootNode.firstChild; while (node) { switch (node.localName) { case 'Issuer': issuer = node.firstChild.data; break; case 'Signature': signature = validateXmlSignature(xml, node); break; case 'Status': status = xmlStatusOk(node); break; case 'Assertion': assertion = parseAssertion(xml, node); break; default: // errors.push('Unknown/unsupported top XML node: ' + node.localName + ', prefix: ' + node.prefix + '
\n'); break; } node = node.nextSibling; } if (issuer === null) errors.push('Issuer is not set'); else if (issuer !== IdPIssuer) errors.push('Invalid IdP issuer: ' + issuer); if (signature === null) errors.push('the response is not signed'); else if (!signature) errors.push('IdP signature is invalid'); if (status === null) errors.push('status is not set'); else if (!status) errors.push('response status is not OK'); if (!assertion) errors.push('no assertions supplied, no attributes present'); else { if ((typeof assertion.signatureOk !== 'undefined') && (!assertion.signatureOk)) errors.push('assertion signature is invalid'); samlData = {}; node = assertion.subject; if (!node) errors.push('assertion subject is not set'); else { node = node.NameID; if (!node) errors.push('assertion subject\'s NameID is not set'); else { if (!node['#text']) errors.push('assertion subject\'s NameID is empty'); samlData['NameID'] = node; } } if (assertion.SessionIndex) samlData['SessionIndex'] = assertion.SessionIndex; node = assertion.attributes; if (!node) errors.push('no attributes supplied'); else { userData = {}; samlData.userData = userData; keys = Object.keys(node); for (i = keys.length - 1; i >= 0; i--) { key = keys[i]; userData[key] = node[key]; } } } } } } res.set('Content-Type', 'text/plain'); if (errors.length > 0) { if (errors.length > 1) res.send('Multiple errors:\n ' + errors.join('\n ')); else res.send('Error: ' + errors[0]); if (waiter) waiter.reject(new Error(errors.join(', '))); } else { res.send('OK'); if (waiter) waiter.resolve(samlData); } }); // user logged out, the GET service http_app.get('/Shibboleth.sso/SLO/Redirect', function (req, res) { var form = req.query, delegateToHandler = function (err, buffer, handler) { if (err) { console.log('Error while deflating request: ' + err); res.send('Failed: ' + err); return; } handler(buffer.toString('utf8'), form, res); }; if (form.SAMLRequest) zlib.inflateRaw(new Buffer(form.SAMLRequest, 'base64'), function (err, buffer) {delegateToHandler(err, buffer, handleLogoutRequest);}); else if (form.SAMLResponse) zlib.inflateRaw(new Buffer(form.SAMLResponse, 'base64'), function (err, buffer) {delegateToHandler(err, buffer, handleLogoutResponse);}); else res.send('Invalid SAML logout request.'); }); // ================================================================================ // Web API handler // ================================================================================ http_app.get('/Shibboleth.sso/WaitLogin', function (req, res) { var sid = req.query.sid, waiter; if (!sid) { res.set('Content-Type', 'text/plain'); res.send('Provide sid as a query parameter'); return; } waiter = waitingLogins[sid]; if (!waiter) waitingLogins[sid] = waiter = makeWaiter(sid); waiter.listeners = waiter.listeners + 1; waiter.promise.then(function (jsonResponse) { res.json({'code': 0, 'message': 'OK', 'data': jsonResponse}); delete waitingLogins[sid]; }, function (err) { res.json({'code': 1, 'message': '' + err}); delete waitingLogins[sid]; }).done(); });