mirror of
https://github.com/netzbegruenung/meteor-accounts-saml.git
synced 2024-05-03 04:14:54 +02:00
Initial Commit
This commit is contained in:
commit
2d9496fed9
1
.npm/package/.gitignore
vendored
Normal file
1
.npm/package/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
node_modules
|
7
.npm/package/README
Normal file
7
.npm/package/README
Normal 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
102
.npm/package/npm-shrinkwrap.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
25
package.js
Normal file
25
package.js
Normal 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
69
saml_client.js
Executable 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
155
saml_server.js
Executable 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
388
saml_utils.js
Executable 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' });
|
||||
};
|
Loading…
Reference in a new issue