Initial Commit

This commit is contained in:
Steffo Weber 2015-08-03 16:11:23 +02:00
commit 2d9496fed9
8 changed files with 747 additions and 0 deletions

1
.npm/package/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

7
.npm/package/README Normal file
View file

@ -0,0 +1,7 @@
This directory and the files immediately inside it are automatically generated
when you change this package's NPM dependencies. Commit the files in this
directory (npm-shrinkwrap.json, .gitignore, and this README) to source control
so that others run the same versions of sub-dependencies.
You should NOT check in the node_modules directory that Meteor automatically
creates; if you are using git, the .gitignore file tells git to ignore it.

102
.npm/package/npm-shrinkwrap.json generated Normal file
View file

@ -0,0 +1,102 @@
{
"dependencies": {
"connect": {
"version": "2.7.10",
"dependencies": {
"qs": {
"version": "0.6.5"
},
"formidable": {
"version": "1.0.14"
},
"cookie-signature": {
"version": "1.0.1"
},
"buffer-crc32": {
"version": "0.2.1"
},
"cookie": {
"version": "0.0.5"
},
"send": {
"version": "0.1.0",
"dependencies": {
"mime": {
"version": "1.2.6"
},
"range-parser": {
"version": "0.0.4"
}
}
},
"bytes": {
"version": "0.2.0"
},
"fresh": {
"version": "0.1.0"
},
"pause": {
"version": "0.0.1"
},
"debug": {
"version": "2.2.0",
"dependencies": {
"ms": {
"version": "0.7.1"
}
}
}
}
},
"querystring": {
"version": "0.2.0"
},
"xml-crypto": {
"version": "0.6.0",
"dependencies": {
"xmldom": {
"version": "0.1.19"
},
"xpath.js": {
"version": "1.0.6"
}
}
},
"xml-encryption": {
"version": "0.7.2",
"dependencies": {
"ejs": {
"version": "0.8.8"
},
"async": {
"version": "0.2.10"
},
"xpath": {
"version": "0.0.5"
},
"node-forge": {
"version": "0.2.24"
}
}
},
"xml2js": {
"version": "0.2.0",
"dependencies": {
"sax": {
"version": "1.1.1"
}
}
},
"xmlbuilder": {
"version": "2.6.4",
"dependencies": {
"lodash": {
"version": "3.10.0"
}
}
},
"xmldom": {
"version": "0.1.19"
}
}
}

0
README.md Normal file
View file

25
package.js Normal file
View file

@ -0,0 +1,25 @@
Package.describe({
name:"steffo:meteor-accounts-saml",
summary: "SAML Login (SP) for Meteor",
version: "0.0.1",
git: "https://github.com/steffow/meteor-accounts-saml.git"
});
Package.on_use(function (api) {
api.versionsFrom('1.1.0.2');
api.use(['routepolicy','webapp','underscore', 'service-configuration'], 'server');
api.use(['http','accounts-base'], ['client', 'server']);
api.add_files(['saml_server.js','saml_utils.js'], 'server');
api.add_files('saml_client.js', 'client');
});
Npm.depends({
"xml2js": "0.2.0",
"xml-crypto": "0.6.0",
"xmldom": "0.1.19",
"connect": "2.7.10",
"xmlbuilder": "2.6.4",
"querystring": "0.2.0",
"xml-encryption": "0.7.2"
});

69
saml_client.js Executable file
View file

@ -0,0 +1,69 @@
if (!Accounts.saml) {
Accounts.saml = {};
}
Accounts.saml.initiateLogin = function(options, callback, dimensions) {
// default dimensions that worked well for facebook and google
var popup = openCenteredPopup(
Meteor.absoluteUrl("_saml/authorize/"+options.provider+"/"+options.credentialToken),
(dimensions && dimensions.width) || 650,
(dimensions && dimensions.height) || 500);
var checkPopupOpen = setInterval(function() {
try {
// Fix for #328 - added a second test criteria (popup.closed === undefined)
// to humour this Android quirk:
// http://code.google.com/p/android/issues/detail?id=21061
var popupClosed = popup.closed || popup.closed === undefined;
} catch (e) {
// For some unknown reason, IE9 (and others?) sometimes (when
// the popup closes too quickly?) throws "SCRIPT16386: No such
// interface supported" when trying to read 'popup.closed'. Try
// again in 100ms.
return;
}
if (popupClosed) {
clearInterval(checkPopupOpen);
callback(options.credentialToken);
}
}, 100);
};
var openCenteredPopup = function(url, width, height) {
var screenX = typeof window.screenX !== 'undefined'
? window.screenX : window.screenLeft;
var screenY = typeof window.screenY !== 'undefined'
? window.screenY : window.screenTop;
var outerWidth = typeof window.outerWidth !== 'undefined'
? window.outerWidth : document.body.clientWidth;
var outerHeight = typeof window.outerHeight !== 'undefined'
? window.outerHeight : (document.body.clientHeight - 22);
// XXX what is the 22?
// Use `outerWidth - width` and `outerHeight - height` for help in
// positioning the popup centered relative to the current window
var left = screenX + (outerWidth - width) / 2;
var top = screenY + (outerHeight - height) / 2;
var features = ('width=' + width + ',height=' + height +
',left=' + left + ',top=' + top + ',scrollbars=yes');
var newwindow = window.open(url, 'Login', features);
if (newwindow.focus)
newwindow.focus();
return newwindow;
};
Meteor.loginWithSaml = function(options, callback) {
options = options || {};
var credentialToken = Random.id();
options.credentialToken = credentialToken;
Accounts.saml.initiateLogin(options, function(error, result){
Accounts.callLoginMethod({
methodArguments: [{saml:true,credentialToken:credentialToken}],
userCallback: callback
});
});
};

155
saml_server.js Executable file
View file

@ -0,0 +1,155 @@
if (!Accounts.saml) {
Accounts.saml = {};
}
var Fiber = Npm.require('fibers');
var connect = Npm.require('connect');
RoutePolicy.declare('/_saml/', 'network');
Accounts.registerLoginHandler(function(loginRequest) {
if(!loginRequest.saml || !loginRequest.credentialToken) {
return undefined;
}
var loginResult = Accounts.saml.retrieveCredential(loginRequest.credentialToken);
if(loginResult && loginResult.profile && loginResult.profile.email){
var user = Meteor.users.findOne({'emails.address':loginResult.profile.email});
if(!user)
throw new Error("Could not find an existing user with supplied email " + loginResult.profile.email);
//creating the token and adding to the user
var stampedToken = Accounts._generateStampedLoginToken();
Meteor.users.update(user,
{$push: {'services.resume.loginTokens': stampedToken}}
);
//sending token along with the userId
var result = {
userId: user._id,
token: stampedToken.token
};
return result
}else{
throw new Error("SAML Profile did not contain an email address");
}
});
Accounts.saml._loginResultForCredentialToken = {};
Accounts.saml.hasCredential = function(credentialToken) {
return _.has(Accounts.saml._loginResultForCredentialToken, credentialToken);
}
Accounts.saml.retrieveCredential = function(credentialToken) {
// The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check.
var result = Accounts.saml._loginResultForCredentialToken[credentialToken];
delete Accounts.saml._loginResultForCredentialToken[credentialToken];
return result;
}
// Listen to incoming SAML http requests
WebApp.connectHandlers.use(connect.bodyParser()).use(function(req, res, next) {
// Need to create a Fiber since we're using synchronous http calls and nothing
// else is wrapping this in a fiber automatically
Fiber(function () {
middleware(req, res, next);
}).run();
});
middleware = function (req, res, next) {
// Make sure to catch any exceptions because otherwise we'd crash
// the runner
try {
var samlObject = samlUrlToObject(req.url);
console.log("In middleware: ");
if(!samlObject || !samlObject.serviceName){
next();
return;
}
if(!samlObject.actionName)
throw new Error("Missing SAML action");
var service = _.find(Meteor.settings.saml, function(samlSetting){
return samlSetting.provider === samlObject.serviceName;
});
// Skip everything if there's no service set by the saml middleware
if (!service)
throw new Error("Unexpected SAML service " + samlObject.serviceName);
if(samlObject.actionName === "metadata") {
_saml = new SAML(service);
service.callbackUrl = Meteor.absoluteUrl("_saml/validate/"+service.provider);
res.writeHead(200);
res.write(_saml.generateServiceProviderMetadata(service.callbackUrl));
closePopup(res);
}
if(samlObject.actionName === "authorize"){
service.callbackUrl = Meteor.absoluteUrl("_saml/validate/"+service.provider);
service.id = samlObject.credentialToken;
_saml = new SAML(service);
_saml.getAuthorizeUrl(req, function (err, url) {
if(err)
throw new Error("Unable to generate authorize url");
res.writeHead(302, {'Location': url});
res.end();
});
}else if (samlObject.actionName === "validate"){
_saml = new SAML(service);
_saml.validateResponse(req.body.SAMLResponse, req.body.RelayState, function (err, profile, loggedOut) {
if(err)
throw new Error("Unable to validate response url: " + err);
var credentialToken = profile.inResponseToId || profile.InResponseTo || samlObject.credentialToken;
if(!credentialToken)
throw new Error("Unable to determine credentialToken");
console.log("Checking CT: " + credentialToken);
Accounts.saml._loginResultForCredentialToken[credentialToken] = {
profile: profile
};
closePopup(res);
});
}else {
throw new Error("Unexpected SAML action " + samlObject.actionName);
}
} catch (err) {
closePopup(res, err);
}
};
var samlUrlToObject = function (url) {
// req.url will be "/_saml/<action>/<service name>/<credentialToken>"
if(!url)
return null;
var splitPath = url.split('/');
// Any non-saml request will continue down the default
// middlewares.
if (splitPath[1] !== '_saml')
return null;
var result = {
actionName:splitPath[2],
serviceName:splitPath[3],
credentialToken:splitPath[4]
};
console.log(result);
return result;
};
var closePopup = function(res, err) {
res.writeHead(200, {'Content-Type': 'text/html'});
var content =
'<html><head><script>window.close()</script></head><body><H1>Verified</H1></body></html>';
if(err)
content = '<html><body><h2>Sorry, an annoying error occured</h2><div>'+err+'</div><a onclick="window.close();">Close Window</a></body></html>';
res.end(content, 'utf-8');
};

388
saml_utils.js Executable file
View file

@ -0,0 +1,388 @@
var zlib = Npm.require('zlib');
var xml2js = Npm.require('xml2js');
var xmlCrypto = Npm.require('xml-crypto');
var crypto = Npm.require('crypto');
var xmldom = Npm.require('xmldom');
var querystring = Npm.require('querystring');
var xmlbuilder = Npm.require('xmlbuilder');
var xmlenc = Npm.require('xml-encryption');
var xpath = xmlCrypto.xpath;
var Dom = xmldom.DOMParser;
var prefixMatch = new RegExp(/(?!xmlns)^.*:/);
SAML = function (options) {
this.options = this.initialize(options);
};
var stripPrefix = function(str) {
return str.replace(prefixMatch, '');
};
SAML.prototype.initialize = function (options) {
if (!options) {
options = {};
}
if (!options.protocol) {
options.protocol = 'https://';
}
if (!options.path) {
options.path = '/saml/consume';
}
if (!options.issuer) {
options.issuer = 'onelogin_saml';
}
if (options.identifierFormat === undefined) {
options.identifierFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
}
if (options.authnContext === undefined) {
options.authnContext = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport";
}
return options;
};
SAML.prototype.generateUniqueID = function () {
var chars = "abcdef0123456789";
var uniqueID = "";
for (var i = 0; i < 20; i++) {
uniqueID += chars.substr(Math.floor((Math.random()*15)), 1);
}
return uniqueID;
};
SAML.prototype.generateInstant = function () {
var date = new Date();
return date.getUTCFullYear() + '-' + ('0' + (date.getUTCMonth()+1)).slice(-2) + '-' + ('0' + date.getUTCDate()).slice(-2) + 'T' + ('0' + (date.getUTCHours()+2)).slice(-2) + ":" + ('0' + date.getUTCMinutes()).slice(-2) + ":" + ('0' + date.getUTCSeconds()).slice(-2) + "Z";
};
SAML.prototype.signRequest = function (xml) {
var signer = crypto.createSign('RSA-SHA1');
signer.update(xml);
return signer.sign(this.options.privateKey, 'base64');
}
SAML.prototype.generateAuthorizeRequest = function (req) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
// Post-auth destination
if (this.options.callbackUrl) {
callbackUrl = this.options.callbackUrl;
} else {
var callbackUrl = this.options.protocol + req.headers.host + this.options.path;
}
if (this.options.id)
id = this.options.id;
var request =
"<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"" + id + "\" Version=\"2.0\" IssueInstant=\"" + instant +
"\" ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" AssertionConsumerServiceURL=\"" + callbackUrl + "\" Destination=\"" +
this.options.entryPoint + "\">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">" + this.options.issuer + "</saml:Issuer>\n";
if (this.options.identifierFormat) {
request += "<samlp:NameIDPolicy xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Format=\"" + this.options.identifierFormat +
"\" AllowCreate=\"true\"></samlp:NameIDPolicy>\n";
}
request +=
"<samlp:RequestedAuthnContext xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Comparison=\"exact\">" +
"<saml:AuthnContextClassRef xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext>\n" +
"</samlp:AuthnRequest>";
return request;
};
SAML.prototype.generateLogoutRequest = function (req) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
//samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
// ID="_135ad2fd-b275-4428-b5d6-3ac3361c3a7f" Version="2.0" Destination="https://idphost/adfs/ls/"
//IssueInstant="2008-06-03T12:59:57Z"><saml:Issuer>myhost</saml:Issuer><NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
//NameQualifier="https://idphost/adfs/ls/">myemail@mydomain.com</NameID<samlp:SessionIndex>_0628125f-7f95-42cc-ad8e-fde86ae90bbe
//</samlp:SessionIndex></samlp:LogoutRequest>
var request = "<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" "+
"xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\""+id+"\" Version=\"2.0\" IssueInstant=\""+instant+
"\" Destination=\""+this.options.entryPoint + "\">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">" + this.options.issuer + "</saml:Issuer>"+
"<saml:NameID Format=\""+req.user.nameIDFormat+"\">"+req.user.nameID+"</saml:NameID>"+
"</samlp:LogoutRequest>";
return request;
}
SAML.prototype.requestToUrl = function (request, operation, callback) {
var self = this;
zlib.deflateRaw(request, function(err, buffer) {
if (err) {
return callback(err);
}
var base64 = buffer.toString('base64');
var target = self.options.entryPoint;
if (operation === 'logout') {
if (self.options.logoutUrl) {
target = self.options.logoutUrl;
}
}
if(target.indexOf('?') > 0)
target += '&';
else
target += '?';
var samlRequest = {
SAMLRequest: base64
};
if (self.options.privateCert) {
samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest));
}
// TBD. We should really include a proper RelayState here
target += querystring.stringify(samlRequest) + "&RelayState=12345";
callback(null, target);
});
}
SAML.prototype.getAuthorizeUrl = function (req, callback) {
var request = this.generateAuthorizeRequest(req);
this.requestToUrl(request, 'authorize', callback);
};
SAML.prototype.getLogoutUrl = function(req, callback) {
var request = this.generateLogoutRequest(req);
this.requestToUrl(request, 'logout', callback);
}
SAML.prototype.certToPEM = function (cert) {
cert = cert.match(/.{1,64}/g).join('\n');
cert = "-----BEGIN CERTIFICATE-----\n" + cert;
cert = cert + "\n-----END CERTIFICATE-----\n";
return cert;
};
function findChilds(node, localName, namespace) {
var res = []
for (var i = 0; i<node.childNodes.length; i++) {
var child = node.childNodes[i]
if (child.localName==localName && (child.namespaceURI==namespace || !namespace)) {
res.push(child)
}
}
return res
}
SAML.prototype.validateSignature = function (xml, cert) {
var self = this;
var doc = new xmldom.DOMParser().parseFromString(xml);
var signature = xmlCrypto.xpath(doc, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']")[0];
var sig = new xmlCrypto.SignedXml();
sig.keyInfoProvider = {
getKeyInfo: function (key) {
return "<X509Data></X509Data>"
},
getKey: function (keyInfo) {
return self.certToPEM(cert);
}
};
sig.loadSignature(signature);
return sig.checkSignature(xml);
};
SAML.prototype.getElement = function (parentElement, elementName) {
if (parentElement['saml:' + elementName]) {
return parentElement['saml:' + elementName];
} else if (parentElement['samlp:'+elementName]) {
return parentElement['samlp:'+elementName];
} else if (parentElement['saml2p:'+elementName]) {
return parentElement['saml2p:'+elementName];
} else if (parentElement['saml2:'+elementName]) {
return parentElement['saml2:'+elementName];
}
return parentElement[elementName];
}
SAML.prototype.validateResponse = function (samlResponse, relayState, callback) {
var self = this;
var xml = new Buffer(samlResponse, 'base64').toString('ascii');
// TBD. We currently make to use of RelayState, but that nonce should really go in a Mongo entry
console.log("Validating response with relay state: " + relayState);
var parser = new xml2js.Parser({explicitRoot:true});
var p = new xml2js.Parser({explicitRoot:true});
p.parseString(xml, function (err, result) {
console.log(result);
});
parser.parseString(xml, function (err, doc) {
// Verify signature
console.log("Verify signature");
if (self.options.cert && !self.validateSignature(xml, self.options.cert)) {
console.log("Signature WRONG");
return callback(new Error('Invalid signature'), null, false);
}
console.log("Signature OK");
var response = self.getElement(doc, 'Response');
console.log("Got response");
if (response) {
var assertion = self.getElement(response, 'Assertion');
if (!assertion) {
return callback(new Error('Missing SAML assertion'), null, false);
}
profile = {};
if (response['$'] && response['$']['InResponseTo']){
profile.inResponseToId = response['$']['InResponseTo'];
}
var issuer = self.getElement(assertion[0], 'Issuer');
if (issuer) {
profile.issuer = issuer[0];
}
var subject = self.getElement(assertion[0], 'Subject');
if (subject) {
var nameID = self.getElement(subject[0], 'NameID');
if (nameID) {
profile.nameID = nameID[0]["_"];
if (nameID[0]['$'].Format) {
profile.nameIDFormat = nameID[0]['$'].Format;
}
}
}
var attributeStatement = self.getElement(assertion[0], 'AttributeStatement');
if (attributeStatement) {
var attributes = self.getElement(attributeStatement[0], 'Attribute');
if (attributes) {
attributes.forEach(function (attribute) {
var value = self.getElement(attribute, 'AttributeValue');
if (typeof value[0] === 'string') {
profile[attribute['$'].Name] = value[0];
} else {
profile[attribute['$'].Name] = value[0]['_'];
}
});
}
if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) {
// See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs
profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3'];
}
if (!profile.email && profile.mail) {
profile.email = profile.mail;
}
}
if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) {
profile.email = profile.nameID;
}
callback(null, profile, false);
} else {
var logoutResponse = self.getElement(doc, 'LogoutResponse');
if (logoutResponse){
callback(null, null, true);
} else {
return callback(new Error('Unknown SAML response message'), null, false);
}
}
});
};
//SAML.prototype(generateServiceProviderMetadata(options.privateCert)
SAML.prototype.generateServiceProviderMetadata = function( callbackUrl ) {
var keyDescriptor = null;
if (!decryptionCert) {
decryptionCert = this.options.privateCert;
}
if (this.options.privateKey) {
if (!decryptionCert) {
throw new Error(
"Missing decryptionCert while generating metadata for decrypting service provider");
}
decryptionCert = decryptionCert.replace( /-+BEGIN CERTIFICATE-+\r?\n?/, '' );
decryptionCert = decryptionCert.replace( /-+END CERTIFICATE-+\r?\n?/, '' );
decryptionCert = decryptionCert.replace( /\r\n/g, '\n' );
keyDescriptor = {
'ds:KeyInfo' : {
'ds:X509Data' : {
'ds:X509Certificate': {
'#text': decryptionCert
}
}
},
'#list' : [
// this should be the set that the xmlenc library supports
{ 'EncryptionMethod': { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc' } },
{ 'EncryptionMethod': { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes128-cbc' } },
{ 'EncryptionMethod': { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc' } },
]
};
}
if (!this.options.callbackUrl && !callbackUrl) {
throw new Error(
"Unable to generate service provider metadata when callbackUrl option is not set");
}
var metadata = {
'EntityDescriptor' : {
'@xmlns': 'urn:oasis:names:tc:SAML:2.0:metadata',
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'@entityID': this.options.issuer,
'SPSSODescriptor' : {
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
'KeyDescriptor' : keyDescriptor,
'NameIDFormat' : this.options.identifierFormat,
'AssertionConsumerService' : {
'@index': '1',
'@isDefault': 'true',
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
'@Location': callbackUrl
}
},
}
};
return xmlbuilder.create(metadata).end({ pretty: true, indent: ' ', newline: '\n' });
};