Add package meteor-accounts-saml

pull/664/head
Rodrigo Nascimento 10 years ago
parent f189b4d5ec
commit e93736616b
  1. 1
      .meteor/packages
  2. 1
      .meteor/versions
  3. 123
      packages/meteor-accounts-saml/README.md
  4. 25
      packages/meteor-accounts-saml/package.js
  5. 76
      packages/meteor-accounts-saml/saml_client.js
  6. 298
      packages/meteor-accounts-saml/saml_server.js
  7. 516
      packages/meteor-accounts-saml/saml_utils.js

@ -82,3 +82,4 @@ monbro:mongodb-mapreduce-aggregation
rocketchat:custom-oauth
rocketchat:gitlab
rocketchat:statistics
steffo:meteor-accounts-saml

@ -127,6 +127,7 @@ simple:json-routes@1.0.4
spacebars@1.0.6
spacebars-compiler@1.0.6
srp@1.0.3
steffo:meteor-accounts-saml@0.0.1
tap:i18n@1.5.1
templating@1.1.1
tmeasday:crypto-base@3.1.2

@ -0,0 +1,123 @@
Meteor-accounts-saml
==================
SAML v2 login support for existing password based accounts
-----
## Demo
For OpenIDP, see the example app `example-openidp` and http://accounts-saml-example.meteor.com/ for a demo.
For OpenAM, see the example app `openam-example`.
## Important Notes
* **This package is working but may have issues with various saml providers** - it has only been tested and verified with [OpenIDP](https://openidp.feide.no/) and [OpenAM](https://www.forgerock.org/openam).
* Most SAML IDPs don't allow SPs with a _localhost (127.0.0.1)_ address. Unless you run your own IDP (eg via your own OpenAM instance) you might exprience issues.
* The accounts-ui loggin buttons will not include saml providers, this may be implemented as a future enhancement, see below for how to build a custom login button.
## Usage
Put SAML settings in eg `server/lib/settings.js` like so:
```
settings = {"saml":[{
"provider":"openam",
"entryPoint":"https://openam.idp.io/openam/SSORedirect/metaAlias/zimt/idp",
"issuer": "https://sp.zimt.io/", //replace with url of your app
"cert":"MIICizCCAfQCCQCY8tKaMc0 LOTS OF FUNNY CHARS ==",
"idpSLORedirectURL": "http://openam.idp.io/openam/IDPSloRedirect/metaAlias/zimt/idp",
"privateKeyFile": "certs/mykey.pem", // path is relative to $METEOR-PROJECT/private
"publicCertFile": "certs/mycert.pem" // eg $METEOR-PROJECT/private/certs/mycert.pem
}]}
Meteor.settings = settings;
```
in some template
```
<a href="#" class="saml-login" data-provider="openam">OpenAM</a>
```
in helper function
```
'click .saml-login': function(event, template){
event.preventDefault();
var provider = $(event.target).data('provider');
Meteor.loginWithSaml({
provider:provider
}, function(error, result){
//handle errors and result
});
}
```
and if SingleLogout is needed
```
'click .saml-login': function(event, template){
event.preventDefault();
var provider = $(event.target).data('provider');
Meteor.logoutWithSaml({
provider:provider
}, function(error, result){
//handle errors and result
});
}
```
## Setup SAML SP (Consumer)
1. Create a Meteor project by `meteor create sp` and cd into it.
2. Add `steffo:meteor-accounts-saml`
3. Create `server/lib/settings.js` as described above. Since Meteor loads things in `server/lib` first, this ensures that your settings are respected even on Galaxy where you cannot use `meteor --settings`.
4. Put your private key and your cert (not the IDP's one) into the "private" directory. Eg if your meteor project is at `/Users/steffo/sp` then place them in `/Users/steffo/sp/private`
5. Check if you can receive SP metadata eg via `curl http://localhost:3000/_saml/metadata/openam`. Output should look like:
```
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="http://localhost:3000/">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MKA.... lot of funny chars ... gkqhkiG9w0BAQUFADATMREwDwYDVQQDEwgxMC4w
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<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"/>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3000/_saml/validate/openam"/>
</SPSSODescriptor>
</EntityDescriptor>
```
##OpenAM Setup
1. I prefer using OpenAM realms. Set up a realm using a name that matches the one in the entry point URL of the `settings.json` file: `https://openam.idp.io/openam/SSORedirect/metaAlias/<YOURREALM>/idp`; we used `zimt` above.
2. Save the SP metadata (obtained in Step 5 above) in a file `sp-metadata.xml`.
3. Logon OpenSSO console as `amadmin` and select _Common Tasks > Register Remote Service Provider_
4. Select the corresponding real and upload the metadata (alternatively, point OpenAM to the SP's metadata URL eg `http://sp.meteor.com/_saml/metadata/openam`). If all goes well the new SP shows up under _Federation > Entity Providers_
## OpenIDP setup
- EntryID = http://accounts-saml-example.meteor.com
- Name of Service = meteor-accounts-saml-example
- AssertionConsumerService endpoint = http://accounts-saml-example.meteor.com/_saml/validate/openidp/
## Roadmap
* Introduction of IDP types (eg openam, auth0 etc) to support implementaion specific workarounds.
* Support for different SAML Bindings
* Check for better/alternative SAML profile. I have the impression that the SAML Web Browser SSO profile somewhat conflicts with Meteor's DDP/websocket approach. Eg when the browser issues an HTTP request, Meteor apps don't necessarily know from which user/session this request comes from (ja, we could use cookies but that's not the the Meteor-way).
## Credits
Based Nat Strauser's Meteor/SAML package _natestrauser:meteor-accounts-saml_ which is
heavily derived from https://github.com/bergie/passport-saml.

@ -0,0 +1,25 @@
Package.describe({
name:"steffo:meteor-accounts-saml",
summary: "SAML Login (SP) for Meteor. Works with OpenAM, OpenIDP and provides Single Logout.",
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"
});

@ -0,0 +1,76 @@
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
});
});
};
Meteor.logoutWithSaml = function (options, callback) {
//Accounts.saml.idpInitiatedSLO(options, callback);
Meteor.call("samlLogout", options.provider, function (err, result) {
console.log("LOC " + result);
// A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server.
//window.location.replace(Meteor.absoluteUrl("_saml/sloRedirect/" + options.provider + "/?redirect="+result));
window.location.replace(Meteor.absoluteUrl("_saml/sloRedirect/" + options.provider + "/?redirect="+encodeURIComponent(result)));
});
};

@ -0,0 +1,298 @@
if (!Accounts.saml) {
Accounts.saml = {};
}
var Fiber = Npm.require('fibers');
var connect = Npm.require('connect');
RoutePolicy.declare('/_saml/', 'network');
Meteor.methods({
samlLogout: function (provider) {
// Make sure the user is logged in before initiate SAML SLO
if (!Meteor.userId()) {
throw new Meteor.Error("not-authorized");
}
var samlProvider = function (element) {
return (element.provider == provider)
}
providerConfig = Meteor.settings.saml.filter(samlProvider)[0];
if (Meteor.settings.debug) {
console.log("Logout request from " + JSON.stringify(providerConfig));
}
// This query should respect upcoming array of SAML logins
var user = Meteor.users.findOne({
_id: Meteor.userId(),
"services.saml.provider": provider
}, {
"services.saml": 1
});
var nameID = user.services.saml.nameID;
var sessionIndex = nameID = user.services.saml.idpSession;
if (Meteor.settings.debug) {
console.log("NameID for user " + Meteor.userId() + " found: " + JSON.stringify(nameID));
}
_saml = new SAML(providerConfig);
var request = _saml.generateLogoutRequest({
nameID: nameID,
sessionIndex: sessionIndex
});
// request.request: actual XML SAML Request
// request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse
Meteor.users.update({
_id: Meteor.userId()
}, {
$set: {
'services.saml.inResponseTo': request.id
}
});
var _syncRequestToUrl = Meteor.wrapAsync(_saml.requestToUrl, _saml);
var result = _syncRequestToUrl(request.request, "logout");
if (Meteor.settings.debug) {
console.log("SAML Logout Request " + result);
}
return result;
}
})
Accounts.registerLoginHandler(function (loginRequest) {
if (!loginRequest.saml || !loginRequest.credentialToken) {
return undefined;
}
var loginResult = Accounts.saml.retrieveCredential(loginRequest.credentialToken);
if (Meteor.settings.debug) {
console.log("RESULT :" + JSON.stringify(loginResult));
}
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
}
});
var samlLogin = {
provider: Accounts.saml.RelayState,
idp: loginResult.profile.issuer,
idpSession: loginResult.profile.sessionIndex,
nameID: loginResult.profile.nameID
};
Meteor.users.update({
_id: user._id
}, {
$set: {
// TBD this should be pushed, otherwise we're only able to SSO into a single IDP at a time
'services.saml': samlLogin
}
});
//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);
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);
switch (samlObject.actionName) {
case "metadata":
_saml = new SAML(service);
service.callbackUrl = Meteor.absoluteUrl("_saml/validate/" + service.provider);
res.writeHead(200);
res.write(_saml.generateServiceProviderMetadata(service.callbackUrl));
res.end();
//closePopup(res);
break;
case "logout":
// This is where we receive SAML LogoutResponse
_saml = new SAML(service);
_saml.validateLogoutResponse(req.query.SAMLResponse, function (err, result) {
if (!err) {
var logOutUser = function (inResponseTo) {
if (Meteor.settings.debug) {
console.log("Logging Out user via inResponseTo " + inResponseTo);
}
var loggedOutUser = Meteor.users.find({
'services.saml.inResponseTo': inResponseTo
}).fetch();
if (loggedOutUser.length == 1) {
if (Meteor.settings.debug) {
console.log("Found user " + loggedOutUser[0]._id);
}
Meteor.users.update({
_id: loggedOutUser[0]._id
}, {
$set: {
"services.resume.loginTokens": []
}
});
Meteor.users.update({
_id: loggedOutUser[0]._id
}, {
$unset: {
"services.saml": ""
}
});
} else {
throw new Meteor.error("Found multiple users matching SAML inResponseTo fields");
}
}
Fiber(function () {
logOutUser(result);
}).run();
res.writeHead(302, {
'Location': req.query.RelayState
});
res.end();
} else {
// TBD thinking of sth meaning full.
}
})
break;
case "sloRedirect":
var idpLogout = req.query.redirect
res.writeHead(302, {
// credentialToken here is the SAML LogOut Request that we'll send back to IDP
'Location': idpLogout
});
res.end();
break;
case "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();
});
break;
case "validate":
_saml = new SAML(service);
Accounts.saml.RelayState = req.body.RelayState;
_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");
Accounts.saml._loginResultForCredentialToken[credentialToken] = {
profile: profile
};
closePopup(res);
});
break;
default:
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]
};
if (Meteor.settings.debug) {
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');
};

@ -0,0 +1,516 @@
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 (options) {
// options should be of the form
// nameId: <nameId as submitted during SAML SSO>
// sessionIndex: sessionIndex
// --- NO SAMLsettings: <Meteor.setting.saml entry for the provider you want to SLO from
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
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.idpSLORedirectURL + "\">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">" + this.options.issuer + "</saml:Issuer>" +
"<saml:NameID Format=\"" + this.options.identifierFormat + "\">" + options.nameID + "</saml:NameID>" +
"</samlp:LogoutRequest>";
request = "<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" " +
"ID=\"" + id + "\" " +
"Version=\"2.0\" " +
"IssueInstant=\"" + instant + "\" " +
"Destination=\"" + this.options.idpSLORedirectURL + "\" " +
">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">" + this.options.issuer + "</saml:Issuer>" +
"<saml:NameID xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" " +
"NameQualifier=\"http://id.init8.net:8080/openam\" " +
"SPNameQualifier=\"" + this.options.issuer + "\" " +
"Format=\"" + this.options.identifierFormat + "\">" +
options.nameID + "</saml:NameID>" +
"<samlp:SessionIndex xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\">" + options.sessionIndex + "</samlp:SessionIndex>" +
"</samlp:LogoutRequest>";
if (Meteor.settings.debug) {
console.log("------- SAML Logout request -----------");
console.log(request);
}
return {
request: request,
id: id
};
}
SAML.prototype.requestToUrl = function (request, operation, callback) {
var self = this;
var result;
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.idpSLORedirectURL) {
target = self.options.idpSLORedirectURL;
}
}
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
if (operation === 'logout') {
// in case of logout we want to be redirected back to the Meteor app.
var relayState = Meteor.absoluteUrl();
} else {
var relayState = self.options.provider;
}
target += querystring.stringify(samlRequest) + "&RelayState=" + relayState;
if (Meteor.settings.debug) {
console.log("requestToUrl: " + target);
}
if (operation === 'logout') {
// in case of logout we want to be redirected back to the Meteor app.
result = target;
return callback(null, target);
} else {
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.validateLogoutResponse = function (samlResponse, callback) {
var self = this;
var compressedSAMLResponse = new Buffer(samlResponse, 'base64');
zlib.inflateRaw(compressedSAMLResponse, function (err, decoded) {
if (err) {
if (Meteor.settings.debug) {
console.log(err)
}
} else {
var parser = new xml2js.Parser({
explicitRoot: true
});
parser.parseString(decoded, function (err, doc) {
var response = self.getElement(doc, 'LogoutResponse');
if (response) {
// TBD. Check if this msg corresponds to one we sent
var inResponseTo = response['$'].InResponseTo;
if (Meteor.settings.debug) {
console.log("In Response to: " + inResponseTo);
}
var status = self.getElement(response, 'Status');
var statusCode = self.getElement(status[0], 'StatusCode')[0]['$'].Value;
if (Meteor.settings.debug) {
console.log("StatusCode: " + JSON.stringify(statusCode));
}
if (statusCode === 'urn:oasis:names:tc:SAML:2.0:status:Success') {
// In case of a successful logout at IDP we return inResponseTo value.
// This is the only way how we can identify the Meteor user (as we don't use Session Cookies)
callback(null, inResponseTo);
} else {
callback("Error. Logout not confirmed by IDP", null);
}
} else {
callback("No Response Found", null);
}
})
}
})
}
SAML.prototype.validateResponse = function (samlResponse, relayState, callback) {
var self = this;
var xml = new Buffer(samlResponse, 'base64').toString('ascii');
// We currently use RelayState to save SAML provider
if (Meteor.settings.debug) {
console.log("Validating response with relay state: " + xml);
}
var parser = new xml2js.Parser({
explicitRoot: true
});
parser.parseString(xml, function (err, doc) {
// Verify signature
if (Meteor.settings.debug) {
console.log("Verify signature");
}
if (self.options.cert && !self.validateSignature(xml, self.options.cert)) {
if (Meteor.settings.debug) {
console.log("Signature WRONG");
}
return callback(new Error('Invalid signature'), null, false);
}
if (Meteor.settings.debug) {
console.log("Signature OK");
}
var response = self.getElement(doc, 'Response');
if (Meteor.settings.debug) {
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 authnStatement = self.getElement(assertion[0], 'AuthnStatement');
if (authnStatement) {
if (authnStatement[0]['$'].SessionIndex) {
profile.sessionIndex = authnStatement[0]['$'].SessionIndex;
if (Meteor.settings.debug) {
console.log("Session Index: " + profile.sessionIndex);
}
} else {
if (Meteor.settings.debug) {
console.log("No Session Index Found");
}
}
} else {
if (Meteor.settings.debug) {
console.log("No AuthN Statement found");
}
}
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;
}
if (Meteor.settings.debug) {
console.log("NameID: " + JSON.stringify(profile));
}
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 = 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,
'SingleLogoutService': {
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
'@Location': Meteor.absoluteUrl() + "_saml/logout/" + this.options.provider + "/",
'@ResponseLocation': Meteor.absoluteUrl() + "_saml/logout/" + this.options.provider + "/"
},
'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…
Cancel
Save