summaryrefslogtreecommitdiff
path: root/saml
diff options
context:
space:
mode:
authorAleš Smodiš <aless@guru.si>2015-10-15 15:09:00 +0200
committerAleš Smodiš <aless@guru.si>2015-10-15 15:09:00 +0200
commitcbc096f9cb44a7d26b4fa01a40dbba594ab339ca (patch)
tree3079dbaf60e7e60d643e66d013fc52d155eaa095 /saml
parentc8c4ca123d24757d3d3537817ba2a40849b3ca2c (diff)
Implemented minimum support for authentication via SAML with an addition of a new daemon.
TODO: python login with SAML credentials.
Diffstat (limited to 'saml')
-rw-r--r--saml/.gitignore1
-rw-r--r--saml/package.json13
-rw-r--r--saml/saml.js642
3 files changed, 656 insertions, 0 deletions
diff --git a/saml/.gitignore b/saml/.gitignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/saml/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/saml/package.json b/saml/package.json
new file mode 100644
index 0000000..f60d0a5
--- /dev/null
+++ b/saml/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "CodeQSaml",
+ "version": "0.0.1",
+ "dependencies": {
+ "bluebird": "^2.10.2",
+ "body-parser": "^1.14.1",
+ "express": "^4.13.3",
+ "log4js": "^0.6.27",
+ "xml-crypto": "^0.8.1",
+ "xmlbuilder": "^3.1.0",
+ "xmldom": "^0.1.19"
+ }
+}
diff --git a/saml/saml.js b/saml/saml.js
new file mode 100644
index 0000000..a363c1e
--- /dev/null
+++ b/saml/saml.js
@@ -0,0 +1,642 @@
+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 + '<br>\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();
+});