parent
f189b4d5ec
commit
e93736616b
@ -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…
Reference in new issue