SAML = function (options) { var self = this; this.options = this.initialize(options); this.cacheProvider = this.options.cacheProvider; }
...
}
this.name = 'saml';
passport.Strategy.call(this);
this._verify = verify;
this._saml = new saml.SAML(options);
this._passReqToCallback = !!options.passReqToCallback;
this._authnRequestBinding = options.authnRequestBinding || 'HTTP-Redirect';
}
util.inherits(Strategy, passport.Strategy);
Strategy.prototype.authenticate = function (req, options) {
...
function Strategy(options, verify) { if (typeof options == 'function') { verify = options; options = {}; } if (!verify) { throw new Error('SAML authentication strategy requires a verify function'); } this.name = 'saml'; passport.Strategy.call(this); this._verify = verify; this._saml = new saml.SAML(options); this._passReqToCallback = !!options.passReqToCallback; this._authnRequestBinding = options.authnRequestBinding || 'HTTP-Redirect'; }
n/a
function Strategy() { }
n/a
SAML = function (options) { var self = this; this.options = this.initialize(options); this.cacheProvider = this.options.cacheProvider; }
...
}
this.name = 'saml';
passport.Strategy.call(this);
this._verify = verify;
this._saml = new saml.SAML(options);
this._passReqToCallback = !!options.passReqToCallback;
this._authnRequestBinding = options.authnRequestBinding || 'HTTP-Redirect';
}
util.inherits(Strategy, passport.Strategy);
Strategy.prototype.authenticate = function (req, options) {
...
certToPEM = function (cert) { cert = cert.match(/.{1,64}/g).join('\n'); if (cert.indexOf('-BEGIN CERTIFICATE-') === -1) cert = "-----BEGIN CERTIFICATE-----\n" + cert; if (cert.indexOf('-END CERTIFICATE-') === -1) cert = cert + "\n-----END CERTIFICATE-----\n"; return cert; }
...
it('#certToPEM should generate valid certificate', function(done){
var samlConfig = {
entryPoint: 'https://app.onelogin.com/trust/saml2/http-post/sso/371755',
cert: '-----BEGIN CERTIFICATE-----MIIEFzCCAv+gAwIBAgIUFJsUjPM7AmWvNtEvULSHlTTMiLQwDQYJKoZIhvcNAQEFBQAwWDELMAkGA1UEBhMCVVMxETAPBgNVBAoMCFN1YnNwYWNlMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxHzAdBgNVBAMMFk9uZUxvZ2luIEFjY291bnQgNDIzNDkwHhcNMTQwNTEzMTgwNjEyWhcNMTkwNTE0MTgwNjEyWjBYMQswCQYDVQQGEwJVUzERMA8GA1UECgwIU3Vic3BhY2UxFTATBgNVBAsMDE9uZUxvZ2luIElkUDEfMB0GA1UEAwwWT25lTG9naW4gQWNjb3VudCA0MjM0OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKrAzJdY9FzFLt5blArJfPzgi87EnFGlTfcV5T1TUDwLBlDkY
/0ZGKnMOpf3D7ie2C4pPFOImOogcM5kpDDL7qxTXZ1ewXVyjBdMu29NG2C6NzWeQTUMUji01EcHkC8o+Pts8ANiNOYcjxEeyhEyzJKgEizblYzMMKzdrOET6QuqWo3C83K
+5+5dsjDn1ooKGRwj3HvgsYcFrQl9NojgQFjoobwsiE/7A+OJhLpBcy/nSVgnoJaMfrO+JsnukZPztbntLvOl56+Vra0N8n5NAYhaSayPiv/ayhjVgjfXd1tjMVTOiDknUOwizZuJ1Y3QH94vUtBgp0WBpBSs
/xMyTs8CAwEAAaOB2DCB1TAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBRQO4WpM5fWwxib49WTuJkfYDbxODCBlQYDVR0jBIGNMIGKgBRQO4WpM5fWwxib49WTuJkfYDbxOKFcpFowWDELMAkGA1UEBhMCVVMxETAPBgNVBAoMCFN1YnNwYWNlMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxHzAdBgNVBAMMFk9uZUxvZ2luIEFjY291bnQgNDIzNDmCFBSbFIzzOwJlrzbRL1C0h5U0zIi0MA4GA1UdDwEB
/wQEAwIHgDANBgkqhkiG9w0BAQUFAAOCAQEACdDAAoaZFCEY5pmfwbKuKrXtO5iE8lWtiCPjCZEUuT6bXRNcqrdnuV/EAfX9WQoXjalPi0eM78zKmbvRGSTUHwWw49RHjFfeJUKvHNeNnFgTXDjEPNhMvh69kHm453lFRmB
+kk6yjtXRZaQEwS8Uuo2Ot+krgNbl6oTBZJ0AHH1MtZECDloms1Km7zsK8wAi5i8TVIKkVr5b2VlhrLgFMvzZ5ViAxIMGB6w47yY4QGQB/5Q8ya9hBs9vkn+wubA+yr4j14JXZ7blVKDSTYva65Ea
+PqHyrp+Wnmnbw2ObS7iWexiTy1jD3G0R2avDBFjM8Fj5DbfufsE1b0U10RTtg==-----END CERTIFICATE-----',
acceptedClockSkewMs: -1
};
var samlObj = new SAML( samlConfig );
var certificate = samlObj.certToPEM(samlConfig.cert);
if (certificate.match(/BEGIN/g).length == 1
&& certificate.match(/END/g).length == 1){
done();
} else {
done('Certificate should have only 1 BEGIN and 1 END block');
}
...
checkTimestampsValidityError = function (nowMs, notBefore, notOnOrAfter) { var self = this; if (self.options.acceptedClockSkewMs == -1) return null; if (notBefore) { var notBeforeMs = Date.parse(notBefore); if (nowMs + self.options.acceptedClockSkewMs < notBeforeMs) return new Error('SAML assertion not yet valid'); } if (notOnOrAfter) { var notOnOrAfterMs = Date.parse(notOnOrAfter); if (nowMs - self.options.acceptedClockSkewMs >= notOnOrAfterMs) return new Error('SAML assertion expired'); } return null; }
...
}
if (subjectConfirmation) {
if (confirmData && confirmData.$) {
var subjectNotBefore = confirmData.$.NotBefore;
var subjectNotOnOrAfter = confirmData.$.NotOnOrAfter;
var subjErr = self.checkTimestampsValidityError(
nowMs, subjectNotBefore, subjectNotOnOrAfter);
if (subjErr) {
throw subjErr;
}
}
}
}
...
generateAuthorizeRequest = function (req, isPassive, callback) { var self = this; var id = "_" + self.generateUniqueID(); var instant = self.generateInstant(); var forceAuthn = self.options.forceAuthn || false; Q.fcall(function() { if(self.options.validateInResponseTo) { return Q.ninvoke(self.cacheProvider, 'save', id, instant); } else { return Q(); } }) .then(function(){ 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': self.getCallbackUrl(req), '@Destination': self.options.entryPoint, 'saml:Issuer' : { '@xmlns:saml' : 'urn:oasis:names:tc:SAML:2.0:assertion', '#text': self.options.issuer } } }; if (isPassive) request['samlp:AuthnRequest']['@IsPassive'] = true; if (forceAuthn) { request['samlp:AuthnRequest']['@ForceAuthn'] = true; } if (self.options.identifierFormat) { request['samlp:AuthnRequest']['samlp:NameIDPolicy'] = { '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', '@Format': self.options.identifierFormat, '@AllowCreate': 'true' }; } if (!self.options.disableRequestedAuthnContext) { request['samlp:AuthnRequest']['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', '#text': self.options.authnContext } }; } if (self.options.attributeConsumingServiceIndex) { request['samlp:AuthnRequest']['@AttributeConsumingServiceIndex'] = self.options.attributeConsumingServiceIndex; } callback(null, xmlbuilder.create(request).end()); }) .fail(function(err){ callback(err); }) .done(); }
...
});
return additionalParams;
};
SAML.prototype.getAuthorizeUrl = function (req, callback) {
var self = this;
self.generateAuthorizeRequest(req, self.options.passive, function(err, request){
if (err)
return callback(err);
var operation = 'authorize';
self.requestToUrl(request, null, operation, self.getAdditionalParams(req, operation), callback);
});
};
...
generateInstant = function () { return new Date().toISOString(); }
...
signer.update(querystring.stringify(samlMessageToSign));
samlMessage.Signature = signer.sign(this.options.privateCert, 'base64');
};
SAML.prototype.generateAuthorizeRequest = function (req, isPassive, callback) {
var self = this;
var id = "_" + self.generateUniqueID();
var instant = self.generateInstant();
var forceAuthn = self.options.forceAuthn || false;
Q.fcall(function() {
if(self.options.validateInResponseTo) {
return Q.ninvoke(self.cacheProvider, 'save', id, instant);
} else {
return Q();
...
generateLogoutRequest = function (req) { 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.logoutUrl, 'saml:Issuer' : { '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '#text': this.options.issuer }, 'saml:NameID' : { '@Format': req.user.nameIDFormat, '#text': req.user.nameID } } }; if (typeof(req.user.nameQualifier) !== 'undefined') { request['samlp:LogoutRequest']['saml:NameID']['@NameQualifier'] = req.user.nameQualifier; } if (typeof(req.user.spNameQualifier) !== 'undefined') { request['samlp:LogoutRequest']['saml:NameID']['@SPNameQualifier'] = req.user.spNameQualifier; } if (req.user.sessionIndex) { request['samlp:LogoutRequest']['saml2p:SessionIndex'] = { '@xmlns:saml2p': 'urn:oasis:names:tc:SAML:2.0:protocol', '#text': req.user.sessionIndex }; } return xmlbuilder.create(request).end(); }
...
Destination: 'foo' },
'saml:Issuer':
[ { _: 'onelogin_saml',
'$': { 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' } } ],
'saml:NameID': [ { _: 'bar', '$': { Format: 'foo' } } ] } };
var samlObj = new SAML( { entryPoint: "foo" } );
var logoutRequest = samlObj.generateLogoutRequest({
user: {
nameIDFormat: 'foo',
nameID: 'bar'
}
});
parseString( logoutRequest, function( err, doc ) {
delete doc['samlp:LogoutRequest']['$']["ID"];
...
generateLogoutResponse = function (req, logoutRequest) { var id = "_" + this.generateUniqueID(); var instant = this.generateInstant(); var request = { 'samlp:LogoutResponse' : { '@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.logoutUrl, '@InResponseTo': logoutRequest.ID, 'saml:Issuer' : { '#text': this.options.issuer }, 'samlp:Status': { 'samlp:StatusCode': { '@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success' } } } }; return xmlbuilder.create(request).end(); }
...
//IssueInstant: '2014-05-29T01:11:32Z',
Destination: 'foo',
InResponseTo: 'quux' },
'saml:Issuer': [ 'onelogin_saml' ],
'samlp:Status': [ { 'samlp:StatusCode': [ { '$': { Value: 'urn:oasis:names:tc:SAML:2.0
:status:Success' } } ] } ] } };
var samlObj = new SAML( { entryPoint: "foo" } );
var logoutRequest = samlObj.generateLogoutResponse({}, { ID: "quux" });
parseString( logoutRequest, function( err, doc ) {
delete doc['samlp:LogoutResponse']['$']["ID"];
delete doc['samlp:LogoutResponse']['$']["IssueInstant"];
doc.should.eql( expectedResponse );
done();
});
});
...
generateServiceProviderMetadata = function ( decryptionCert ) { 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, '@ID': this.options.issuer.replace(/\W/g, '_'), 'SPSSODescriptor' : { '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', }, } }; if (this.options.decryptionPvk) { 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' ); metadata.EntityDescriptor.SPSSODescriptor.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.logoutCallbackUrl) { metadata.EntityDescriptor.SPSSODescriptor.SingleLogoutService = { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', '@Location': this.options.logoutCallbackUrl }; } metadata.EntityDescriptor.SPSSODescriptor.NameIDFormat = this.options.identifierFormat; metadata.EntityDescriptor.SPSSODescriptor.AssertionConsumerService = { '@index': '1', '@isDefault': 'true', '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', '@Location': this.getCallbackUrl({}) }; return xmlbuilder.create(metadata).end({ pretty: true, indent: ' ', newline: '\n' }); }
...
});
});
describe( 'generateServiceProviderMetadata tests /', function() {
function testMetadata( samlConfig, expectedMetadata ) {
var samlObj = new SAML( samlConfig );
var decryptionCert = fs.readFileSync(__dirname + '/static/testshib encryption cert.pem', 'utf-8');
var metadata = samlObj.generateServiceProviderMetadata( decryptionCert );
// splits are to get a nice diff if they don't match for some reason
metadata.split( '\n' ).should.eql( expectedMetadata.split( '\n' ) );
// verify that we are exposed through Strategy as well
var strategy = new SamlStrategy( samlConfig, function() {} );
metadata = strategy.generateServiceProviderMetadata( decryptionCert );
metadata.split( '\n' ).should.eql( expectedMetadata.split( '\n' ) );
...
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; }
...
}
signer.update(querystring.stringify(samlMessageToSign));
samlMessage.Signature = signer.sign(this.options.privateCert, 'base64');
};
SAML.prototype.generateAuthorizeRequest = function (req, isPassive, callback) {
var self = this;
var id = "_" + self.generateUniqueID();
var instant = self.generateInstant();
var forceAuthn = self.options.forceAuthn || false;
Q.fcall(function() {
if(self.options.validateInResponseTo) {
return Q.ninvoke(self.cacheProvider, 'save', id, instant);
} else {
...
getAdditionalParams = function (req, operation) { var additionalParams = {}; var RelayState = req.query && req.query.RelayState || req.body && req.body.RelayState; if (RelayState) { additionalParams.RelayState = RelayState; } var optionsAdditionalParams = this.options.additionalParams || {}; Object.keys(optionsAdditionalParams).forEach(function(k) { additionalParams[k] = optionsAdditionalParams[k]; }); var optionsAdditionalParamsForThisOperation = {}; if (operation == "authorize") { optionsAdditionalParamsForThisOperation = this.options.additionalAuthorizeParams || {}; } if (operation == "logout") { optionsAdditionalParamsForThisOperation = this.options.additionalLogoutParams || {}; } Object.keys(optionsAdditionalParamsForThisOperation).forEach(function(k) { additionalParams[k] = optionsAdditionalParamsForThisOperation[k]; }); return additionalParams; }
...
it ( 'should not pass any additional params by default', function( done ) {
var samlConfig = {
entryPoint: 'https://app.onelogin.com/trust/saml2/http-post/sso/371755',
};
var samlObj = new SAML( samlConfig );
['logout', 'authorize'].forEach( function( operation ) {
var additionalParams = samlObj.getAdditionalParams({}, operation);
additionalParams.should.be.empty
});
done();
});
it ( 'should not pass any additional params by default apart from the RelayState in request query', function( done ) {
...
getAuthorizeForm = function (req, callback) { var self = this; // The quoteattr() function is used in a context, where the result will not be evaluated by javascript // but must be interpreted by an XML or HTML parser, and it must absolutely avoid breaking the syntax // of an element attribute. var quoteattr = function(s, preserveCR) { preserveCR = preserveCR ? ' ' : '\n'; return ('' + s) // Forces the conversion to string. .replace(/&/g, '&') // This MUST be the 1st replacement. .replace(/'/g, ''') // The 4 other predefined entities, required. .replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>') // Add other replacements here for HTML only // Or for XML, only if the named entities are defined in its DTD. .replace(/\r\n/g, preserveCR) // Must be before the next replacement. .replace(/[\r\n]/g, preserveCR); }; var getAuthorizeFormHelper = function(err, buffer) { if (err) { return callback(err); } var operation = 'authorize'; var additionalParameters = self.getAdditionalParams(req, operation); var samlMessage = { SAMLRequest: buffer.toString('base64') }; Object.keys(additionalParameters).forEach(function(k) { samlMessage[k] = additionalParameters[k] || ''; }); var formInputs = Object.keys(samlMessage).map(function(k) { return '<input type="hidden" name="' + k + '" value="' + quoteattr(samlMessage[k]) + '" />'; }).join('\r\n'); callback(null, [ '<!DOCTYPE html>', '<html>', '<head>', '<meta charset="utf-8">', '<meta http-equiv="x-ua-compatible" content="ie=edge">', '</head>', '<body onload="document.forms[0].submit()">', '<noscript>', '<p><strong>Note:</strong> Since your browser does not support JavaScript, you must press the button below once to proceed .</p>', '</noscript>', '<form method="post" action="' + encodeURI(self.options.entryPoint) + '">', formInputs, '<input type="submit" value="Submit" />', '</form>', '<script>document.forms[0].style.display="none";</script>', // Hide the form if JavaScript is enabled '</body>', '</html>' ].join('\r\n')); }; self.generateAuthorizeRequest(req, self.options.passive, function(err, request) { if (err) { return callback(err); } if (self.options.skipRequestCompression) { getAuthorizeFormHelper(null, new Buffer(request, 'utf8')); } else { zlib.deflateRaw(request, getAuthorizeFormHelper); } }); }
...
this._saml.validatePostResponse(req.body, validateCallback);
} else if (req.body && req.body.SAMLRequest) {
this._saml.validatePostRequest(req.body, validateCallback);
} else {
var requestHandler = {
'login-request': function() {
if (self._authnRequestBinding === 'HTTP-POST') {
this._saml.getAuthorizeForm(req, function(err, data) {
if (err) {
self.error(err);
} else {
var res = req.res;
res.send(data);
}
});
...
getAuthorizeUrl = function (req, callback) { var self = this; self.generateAuthorizeRequest(req, self.options.passive, function(err, request){ if (err) return callback(err); var operation = 'authorize'; self.requestToUrl(request, null, operation, self.getAdditionalParams(req, operation), callback); }); }
...
protocol: 'https',
headers: {
host: 'examplesp.com'
}
}
});
it('calls callback with right host', function(done) {
saml.getAuthorizeUrl(req, function(err, target) {
url.parse(target).host.should.equal('exampleidp.com');
done();
});
});
it('calls callback with right protocol', function(done) {
saml.getAuthorizeUrl(req, function(err, target) {
url.parse(target).protocol.should.equal('https:');
...
getCallbackUrl = function (req) { // Post-auth destination if (this.options.callbackUrl) { return this.options.callbackUrl; } else { var host; if (req.headers) { host = req.headers.host; } else { host = this.options.host; } return this.getProtocol(req) + host + this.options.path; } }
...
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': self.getCallbackUrl(req),
'@Destination': self.options.entryPoint,
'saml:Issuer' : {
'@xmlns:saml' : 'urn:oasis:names:tc:SAML:2.0:assertion',
'#text': self.options.issuer
}
}
};
...
getLogoutResponseUrl = function (req, callback) { var response = this.generateLogoutResponse(req, req.samlLogoutRequest); var operation = 'logout'; this.requestToUrl(null, response, operation, this.getAdditionalParams(req, operation), callback); }
...
return self.error(err);
}
if (loggedOut) {
req.logout();
if (profile) {
req.samlLogoutRequest = profile;
return self._saml.getLogoutResponseUrl(req, redirectIfSuccess);
}
return self.pass();
}
var verified = function (err, user, info) {
if (err) {
return self.error(err);
...
getLogoutUrl = function (req, callback) { var request = this.generateLogoutRequest(req); var operation = 'logout'; this.requestToUrl(request, null, operation, this.getAdditionalParams(req, operation), callback); }
...
}
});
} else { // Defaults to HTTP-Redirect
this._saml.getAuthorizeUrl(req, redirectIfSuccess);
}
}.bind(self),
'logout-request': function() {
this._saml.getLogoutUrl(req, redirectIfSuccess);
}.bind(self)
}[options.samlFallback];
if (typeof requestHandler !== 'function') {
return self.fail();
}
...
getProtocol = function (req) { return this.options.protocol || (req.protocol || 'http').concat('://'); }
...
} else {
var host;
if (req.headers) {
host = req.headers.host;
} else {
host = this.options.host;
}
return this.getProtocol(req) + host + this.options.path;
}
};
SAML.prototype.generateUniqueID = function () {
var chars = "abcdef0123456789";
var uniqueID = "";
for (var i = 0; i < 20; i++) {
...
initialize = function (options) { if (!options) { options = {}; } if (!options.path) { options.path = '/saml/consume'; } if (!options.host) { options.host = 'localhost'; } 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"; } if (!options.acceptedClockSkewMs) { // default to no skew options.acceptedClockSkewMs = 0; } if(!options.validateInResponseTo){ options.validateInResponseTo = false; } if(!options.requestIdExpirationPeriodMs){ options.requestIdExpirationPeriodMs = 28800000; // 8 hours } if(!options.cacheProvider){ options.cacheProvider = new InMemoryCacheProvider( {keyExpirationPeriodMs: options.requestIdExpirationPeriodMs }); } if (!options.logoutUrl) { // Default to Entry Point options.logoutUrl = options.entryPoint || ''; } // sha1 or sha256 if (!options.signatureAlgorithm) { options.signatureAlgorithm = 'sha1'; } return options; }
...
var server;
function testForCheck(check) {
return function (done) {
var pp = new passport.Authenticator();
var app = express();
app.use(bodyParser.urlencoded({extended: false}));
app.use(pp.initialize());
var config = check.config;
config.callbackUrl = 'http://localhost:3033/login';
var profile = null;
pp.use(new SamlStrategy(config, function (_profile, done) {
profile = _profile;
done(null, { id: profile.nameID });
})
...
processValidlySignedAssertion = function (xml, inResponseTo, callback) { var self = this; var msg; var parserConfig = { explicitRoot: true, tagNameProcessors: [xml2js.processors.stripPrefix] }; var nowMs = new Date().getTime(); var profile = {}; var assertion; var parser = new xml2js.Parser(parserConfig); Q.ninvoke(parser, 'parseString', xml) .then(function(doc) { assertion = doc.Assertion; var issuer = assertion.Issuer; if (issuer) { profile.issuer = issuer[0]; } var authnStatement = assertion.AuthnStatement; if (authnStatement) { if (authnStatement[0].$ && authnStatement[0].$.SessionIndex) { profile.sessionIndex = authnStatement[0].$.SessionIndex; } } var subject = assertion.Subject; var subjectConfirmation, confirmData; if (subject) { var nameID = subject[0].NameID; if (nameID) { profile.nameID = nameID[0]._ || nameID[0]; if (nameID[0].$ && nameID[0].$.Format) { profile.nameIDFormat = nameID[0].$.Format; profile.nameQualifier = nameID[0].$.NameQualifier; profile.spNameQualifier = nameID[0].$.SPNameQualifier; } } subjectConfirmation = subject[0].SubjectConfirmation ? subject[0].SubjectConfirmation[0] : null; confirmData = subjectConfirmation && subjectConfirmation.SubjectConfirmationData ? subjectConfirmation.SubjectConfirmationData[0] : null; if (subject[0].SubjectConfirmation && subject[0].SubjectConfirmation.length > 1) { msg = 'Unable to process multiple SubjectConfirmations in SAML assertion'; throw new Error(msg); } if (subjectConfirmation) { if (confirmData && confirmData.$) { var subjectNotBefore = confirmData.$.NotBefore; var subjectNotOnOrAfter = confirmData.$.NotOnOrAfter; var subjErr = self.checkTimestampsValidityError( nowMs, subjectNotBefore, subjectNotOnOrAfter); if (subjErr) { throw subjErr; } } } } // Test to see that if we have a SubjectConfirmation InResponseTo that it matches // the 'InResponseTo' attribute set in the Response if (self.options.validateInResponseTo) { if (subjectConfirmation) { if (confirmData && confirmData.$) { var subjectInResponseTo = confirmData.$.InResponseTo; if (inResponseTo && subjectInResponseTo && subjectInResponseTo != inResponseTo) { return Q.ninvoke(self.cacheProvider, 'remove', inResponseTo) .then(function(){ throw new Error('InResponseTo is not valid'); }); } else if (subjectInResponseTo) { var foundValidInResponseTo = false; return Q.ninvoke(self.cacheProvider, 'get', subjectInResponseTo) .then(function(result){ if (result) { var createdAt = new Date(result); if (nowMs < createdAt.getTime() + self.options.requestIdExpirationPeriodMs) foundValidInResponseTo = true; } return Q.ninvoke(self.cacheProvider, 'remove', inResponseTo ); }) .then(function(){ if (!foundValidInResponseTo) { throw new Error('InResponseTo is not valid'); } return Q(); }); } } } else { return Q.ninvoke(self.cacheProvider, 'remove', inResponseTo); } } else { return Q(); } }) .then(function(){ var conditions = assertion.Conditions ? assertion.Conditions[0] : null; if (assertion.Conditions && assertion.Conditions.length > 1) { msg = 'Unable to process multiple conditions in SAML assertion'; throw new Error(msg); } if(conditions && conditions.$) { var conErr = self.checkTimestampsValidityError( nowMs, conditions.$.NotBefore, conditions.$.NotOnOrAfter); if(conErr) throw conErr; ...
...
if (assertions.length == 1) {
if (self.options.cert &&
!validSignature &&
!self.validateSignature(xml, assertions[0], self.options.cert)) {
throw new Error('Invalid signature');
}
return self.processValidlySignedAssertion(assertions[0].toString(), inResponseTo, callback
);
}
if (encryptedAssertions.length == 1) {
if (!self.options.decryptionPvk)
throw new Error('No decryption key for encrypted SAML response');
var encryptedDatas = xpath( encryptedAssertions[0], "./*[local-name()='EncryptedData']");
...
requestToUrl = function (request, response, operation, additionalParameters, callback) { var self = this; if (self.options.skipRequestCompression) requestToUrlHelper(null, new Buffer(request || response, 'utf8')); else zlib.deflateRaw(request || response, requestToUrlHelper); function requestToUrlHelper(err, buffer) { if (err) { return callback(err); } var base64 = buffer.toString('base64'); var target = url.parse(self.options.entryPoint, true); if (operation === 'logout') { if (self.options.logoutUrl) { target = url.parse(self.options.logoutUrl, true); } } else if (operation !== 'authorize') { return callback(new Error("Unknown operation: "+operation)); } var samlMessage = request ? { SAMLRequest: base64 } : { SAMLResponse: base64 }; Object.keys(additionalParameters).forEach(function(k) { samlMessage[k] = additionalParameters[k]; }); if (self.options.privateCert) { // sets .SigAlg and .Signature self.signRequest(samlMessage); } Object.keys(samlMessage).forEach(function(k) { target.query[k] = samlMessage[k]; }); // Delete 'search' to for pulling query string from 'query' // https://nodejs.org/api/url.html#url_url_format_urlobj delete target.search; callback(null, url.format(target)); } }
...
SAML.prototype.getAuthorizeUrl = function (req, callback) {
var self = this;
self.generateAuthorizeRequest(req, self.options.passive, function(err, request){
if (err)
return callback(err);
var operation = 'authorize';
self.requestToUrl(request, null, operation, self.getAdditionalParams(req, operation
), callback);
});
};
SAML.prototype.getAuthorizeForm = function (req, callback) {
var self = this;
// The quoteattr() function is used in a context, where the result will not be evaluated by javascript
...
signRequest = function (samlMessage) { var signer; var samlMessageToSign = {}; switch(this.options.signatureAlgorithm) { case 'sha256': samlMessage.SigAlg = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'; signer = crypto.createSign('RSA-SHA256'); break; default: samlMessage.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; signer = crypto.createSign('RSA-SHA1'); break; } if (samlMessage.SAMLRequest) { samlMessageToSign.SAMLRequest = samlMessage.SAMLRequest; } if (samlMessage.SAMLResponse) { samlMessageToSign.SAMLResponse = samlMessage.SAMLResponse; } if (samlMessage.RelayState) { samlMessageToSign.RelayState = samlMessage.RelayState; } if (samlMessage.SigAlg) { samlMessageToSign.SigAlg = samlMessage.SigAlg; } signer.update(querystring.stringify(samlMessageToSign)); samlMessage.Signature = signer.sign(this.options.privateCert, 'base64'); }
...
};
Object.keys(additionalParameters).forEach(function(k) {
samlMessage[k] = additionalParameters[k];
});
if (self.options.privateCert) {
// sets .SigAlg and .Signature
self.signRequest(samlMessage);
}
Object.keys(samlMessage).forEach(function(k) {
target.query[k] = samlMessage[k];
});
// Delete 'search' to for pulling query string from 'query'
// https://nodejs.org/api/url.html#url_url_format_urlobj
...
validatePostRequest = function (container, callback) { var self = this; var xml = new Buffer(container.SAMLRequest, 'base64').toString('utf8'); var dom = new xmldom.DOMParser().parseFromString(xml); var parserConfig = { explicitRoot: true, tagNameProcessors: [xml2js.processors.stripPrefix] }; var parser = new xml2js.Parser(parserConfig); parser.parseString(xml, function (err, doc) { if (err) { return callback(err); } // Check if this document has a valid top-level signature if (self.options.cert && !self.validateSignature(xml, dom.documentElement, self.options.cert)) { return callback(new Error('Invalid signature')); } processValidlySignedPostRequest(self, doc, callback); }); }
...
});
});
it('errors if bad xml', function(done) {
var body = {
SAMLRequest: "asdf"
};
samlObj.validatePostRequest(body, function(err) {
should.exist(err);
done();
});
});
it('errors if bad signature', function(done) {
var body = {
SAMLRequest: fs.readFileSync(__dirname + '/static/logout_request_with_bad_signature.xml', 'base64')
...
validatePostResponse = function (container, callback) { var self = this; var xml = new Buffer(container.SAMLResponse, 'base64').toString('utf8'); var doc = new xmldom.DOMParser().parseFromString(xml); var inResponseTo = xpath(doc, "/*[local-name()='Response']/@InResponseTo"); if(inResponseTo){ inResponseTo = inResponseTo.length ? inResponseTo[0].nodeValue : null; } Q.fcall(function(){ if(self.options.validateInResponseTo){ if (inResponseTo) { return Q.ninvoke(self.cacheProvider, 'get', inResponseTo) .then(function(result) { if (!result) throw new Error('InResponseTo is not valid'); return Q(); }); } } else { return Q(); } }) .then(function() { // Check if this document has a valid top-level signature var validSignature = false; if (self.options.cert && self.validateSignature(xml, doc.documentElement, self.options.cert)) { validSignature = true; } var assertions = xpath(doc, "/*[local-name()='Response']/*[local-name()='Assertion']"); var encryptedAssertions = xpath(doc, "/*[local-name()='Response']/*[local-name()='EncryptedAssertion']"); if (assertions.length + encryptedAssertions.length > 1) { // There's no reason I know of that we want to handle multiple assertions, and it seems like a // potential risk vector for signature scope issues, so treat this as an invalid signature throw new Error('Invalid signature'); } if (assertions.length == 1) { if (self.options.cert && !validSignature && !self.validateSignature(xml, assertions[0], self.options.cert)) { throw new Error('Invalid signature'); } return self.processValidlySignedAssertion(assertions[0].toString(), inResponseTo, callback); } if (encryptedAssertions.length == 1) { if (!self.options.decryptionPvk) throw new Error('No decryption key for encrypted SAML response'); var encryptedDatas = xpath( encryptedAssertions[0], "./*[local-name()='EncryptedData']"); if (encryptedDatas.length != 1) throw new Error('Invalid signature'); var encryptedDataXml = encryptedDatas[0].toString(); var xmlencOptions = { key: self.options.decryptionPvk }; return Q.ninvoke(xmlenc, 'decrypt', encryptedDataXml, xmlencOptions) .then(function(decryptedXml) { var decryptedDoc = new xmldom.DOMParser().parseFromString(decryptedXml); var decryptedAssertions = xpath(decryptedDoc, "/*[local-name()='Assertion']"); if (decryptedAssertions.length != 1) throw new Error('Invalid EncryptedAssertion content'); if (self.options.cert && !validSignature && !self.validateSignature(decryptedXml, decryptedAssertions[0], self.options.cert)) throw new Error('Invalid signature'); self.processValidlySignedAssertion(decryptedAssertions[0].toString(), inResponseTo, callback); }); } // If there's no assertion, fall back on xml2js response parsing for the status & // LogoutResponse code. var parserConfig = { explicitRoot: true, explicitCharkey: true, tagNameProcessors: [xml2js.processors.stripPrefix] }; var parser = new xml2js.Parser(parserConfig); return Q.ninvoke( parser, 'parseString', xml) .then(function(doc) { var response = doc.Response; if (response) { var assertion = response.Assertion; if (!assertion) { var status = response.Status; if (status) { var statusCode = status[0].StatusCode; if (statusCode && statusCode[0].$.Value === "urn:oasis:names:tc:SAML:2.0:status:Responder") { var nestedStatusCode = statusCode[0].StatusCode; if (nestedStatusCode && nestedStatusCode[0].$.Value === "urn:oasis:names:tc:SAML:2.0:status:NoPassive") { if (self.options.cert && !validSignature) { throw new Error('Invalid signature'); ...
...
it('response with error status message should generate appropriate error', function(done) {
var xml = '<?xml version="1.0" encoding="UTF-8"?><saml2p:Response xmlns:saml2p="urn
:oasis:names:tc:SAML:2.0:protocol" Destination="http://localhost/browserSamlLogin" ID="_6a377272c8662561acf1056274ef3f81
" InResponseTo="_4324fb0d00661146f7dc" IssueInstant="2014-07-02T18:16:31.278Z" Version="2.0"
x3e;<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid
-format:entity">https://idp.testshib.org/idp/shibboleth</saml2:Issuer><saml2p:Status><saml2p:
StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Responder"><saml2p:StatusCode Value="urn:oasis:names
:tc:SAML:2.0:status:InvalidNameIDPolicy"/></saml2p:StatusCode><saml2p:StatusMessage>Required NameID
format not supported</saml2p:StatusMessage></saml2p:Status></saml2p:Response>';
var base64xml = new Buffer( xml ).toString('base64');
var container = { SAMLResponse: base64xml };
var samlObj = new SAML( {
cert: '-----BEGIN CERTIFICATE-----MIIEFzCCAv+gAwIBAgIUFJsUjPM7AmWvNtEvULSHlTTMiLQwDQYJKoZIhvcNAQEFBQAwWDELMAkGA1UEBhMCVVMxETAPBgNVBAoMCFN1YnNwYWNlMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxHzAdBgNVBAMMFk9uZUxvZ2luIEFjY291bnQgNDIzNDkwHhcNMTQwNTEzMTgwNjEyWhcNMTkwNTE0MTgwNjEyWjBYMQswCQYDVQQGEwJVUzERMA8GA1UECgwIU3Vic3BhY2UxFTATBgNVBAsMDE9uZUxvZ2luIElkUDEfMB0GA1UEAwwWT25lTG9naW4gQWNjb3VudCA0MjM0OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKrAzJdY9FzFLt5blArJfPzgi87EnFGlTfcV5T1TUDwLBlDkY
/0ZGKnMOpf3D7ie2C4pPFOImOogcM5kpDDL7qxTXZ1ewXVyjBdMu29NG2C6NzWeQTUMUji01EcHkC8o+Pts8ANiNOYcjxEeyhEyzJKgEizblYzMMKzdrOET6QuqWo3C83K
+5+5dsjDn1ooKGRwj3HvgsYcFrQl9NojgQFjoobwsiE/7A+OJhLpBcy/nSVgnoJaMfrO+JsnukZPztbntLvOl56+Vra0N8n5NAYhaSayPiv/ayhjVgjfXd1tjMVTOiDknUOwizZuJ1Y3QH94vUtBgp0WBpBSs
/xMyTs8CAwEAAaOB2DCB1TAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBRQO4WpM5fWwxib49WTuJkfYDbxODCBlQYDVR0jBIGNMIGKgBRQO4WpM5fWwxib49WTuJkfYDbxOKFcpFowWDELMAkGA1UEBhMCVVMxETAPBgNVBAoMCFN1YnNwYWNlMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxHzAdBgNVBAMMFk9uZUxvZ2luIEFjY291bnQgNDIzNDmCFBSbFIzzOwJlrzbRL1C0h5U0zIi0MA4GA1UdDwEB
/wQEAwIHgDANBgkqhkiG9w0BAQUFAAOCAQEACdDAAoaZFCEY5pmfwbKuKrXtO5iE8lWtiCPjCZEUuT6bXRNcqrdnuV/EAfX9WQoXjalPi0eM78zKmbvRGSTUHwWw49RHjFfeJUKvHNeNnFgTXDjEPNhMvh69kHm453lFRmB
+kk6yjtXRZaQEwS8Uuo2Ot+krgNbl6oTBZJ0AHH1MtZECDloms1Km7zsK8wAi5i8TVIKkVr5b2VlhrLgFMvzZ5ViAxIMGB6w47yY4QGQB/5Q8ya9hBs9vkn+wubA+yr4j14JXZ7blVKDSTYva65Ea
+PqHyrp+Wnmnbw2ObS7iWexiTy1jD3G0R2avDBFjM8Fj5DbfufsE1b0U10RTtg==-----END CERTIFICATE-----',
});
samlObj.validatePostResponse( container, function( err, profile, logout ) {
should.exist( err );
err.message.should.match( /Responder/ );
err.message.should.match( /Required NameID format not supported/ );
should.exist( err.statusXml );
done();
});
});
...
validateSignature = function (fullXml, currentNode, cert) { var self = this; var xpathSigQuery = ".//*[local-name(.)='Signature' and " + "namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"; var signatures = xpath(currentNode, xpathSigQuery); // This function is expecting to validate exactly one signature, so if we find more or fewer // than that, reject. if (signatures.length != 1) return false; var signature = signatures[0]; var sig = new xmlCrypto.SignedXml(); sig.keyInfoProvider = { getKeyInfo: function (key) { return "<X509Data></X509Data>"; }, getKey: function (keyInfo) { return self.certToPEM(cert); } }; sig.loadSignature(signature); // We expect each signature to contain exactly one reference to the top level of the xml we // are validating, so if we see anything else, reject. if (sig.references.length != 1 ) return false; var refUri = sig.references[0].uri; var refId = (refUri[0] === '#') ? refUri.substring(1) : refUri; // If we can't find the reference at the top level, reject var idAttribute = currentNode.getAttribute('ID') ? 'ID' : 'Id'; if (currentNode.getAttribute(idAttribute) != refId) return false; // If we find any extra referenced nodes, reject. (xml-crypto only verifies one digest, so // multiple candidate references is bad news) var totalReferencedNodes = xpath(currentNode.ownerDocument, "//*[@" + idAttribute + "='" + refId + "']"); if (totalReferencedNodes.length > 1) return false; return sig.checkSignature(fullXml); }
...
} else {
return Q();
}
})
.then(function() {
// Check if this document has a valid top-level signature
var validSignature = false;
if (self.options.cert && self.validateSignature(xml, doc.documentElement, self
.options.cert)) {
validSignature = true;
}
var assertions = xpath(doc, "/*[local-name()='Response']/*[local-name()='Assertion']");
var encryptedAssertions = xpath(doc,
"/*[local-name()='Response']/*[local-name()='EncryptedAssertion']");
...
function Strategy(options, verify) { if (typeof options == 'function') { verify = options; options = {}; } if (!verify) { throw new Error('SAML authentication strategy requires a verify function'); } this.name = 'saml'; passport.Strategy.call(this); this._verify = verify; this._saml = new saml.SAML(options); this._passReqToCallback = !!options.passReqToCallback; this._authnRequestBinding = options.authnRequestBinding || 'HTTP-Redirect'; }
n/a
function Strategy() { }
n/a
authenticate = function (req, options) { var self = this; options.samlFallback = options.samlFallback || 'login-request'; function validateCallback(err, profile, loggedOut) { if (err) { return self.error(err); } if (loggedOut) { req.logout(); if (profile) { req.samlLogoutRequest = profile; return self._saml.getLogoutResponseUrl(req, redirectIfSuccess); } return self.pass(); } var verified = function (err, user, info) { if (err) { return self.error(err); } if (!user) { return self.fail(info); } self.success(user, info); }; if (self._passReqToCallback) { self._verify(req, profile, verified); } else { self._verify(profile, verified); } } function redirectIfSuccess(err, url) { if (err) { self.error(err); } else { self.redirect(url); } } if (req.body && req.body.SAMLResponse) { this._saml.validatePostResponse(req.body, validateCallback); } else if (req.body && req.body.SAMLRequest) { this._saml.validatePostRequest(req.body, validateCallback); } else { var requestHandler = { 'login-request': function() { if (self._authnRequestBinding === 'HTTP-POST') { this._saml.getAuthorizeForm(req, function(err, data) { if (err) { self.error(err); } else { var res = req.res; res.send(data); } }); } else { // Defaults to HTTP-Redirect this._saml.getAuthorizeUrl(req, redirectIfSuccess); } }.bind(self), 'logout-request': function() { this._saml.getLogoutUrl(req, redirectIfSuccess); }.bind(self) }[options.samlFallback]; if (typeof requestHandler !== 'function') { return self.fail(); } requestHandler(); } }
...
### Provide the authentication callback
You need to provide a route corresponding to the `path` configuration parameter given to the strategy:
```javascript
app.post('/login/callback',
passport.authenticate('saml', { failureRedirect: '/', failureFlash
: true }),
function(req, res) {
res.redirect('/');
}
);
```
### Authenticate requests
...
generateServiceProviderMetadata = function ( decryptionCert ) { return this._saml.generateServiceProviderMetadata( decryptionCert ); }
...
});
});
describe( 'generateServiceProviderMetadata tests /', function() {
function testMetadata( samlConfig, expectedMetadata ) {
var samlObj = new SAML( samlConfig );
var decryptionCert = fs.readFileSync(__dirname + '/static/testshib encryption cert.pem', 'utf-8');
var metadata = samlObj.generateServiceProviderMetadata( decryptionCert );
// splits are to get a nice diff if they don't match for some reason
metadata.split( '\n' ).should.eql( expectedMetadata.split( '\n' ) );
// verify that we are exposed through Strategy as well
var strategy = new SamlStrategy( samlConfig, function() {} );
metadata = strategy.generateServiceProviderMetadata( decryptionCert );
metadata.split( '\n' ).should.eql( expectedMetadata.split( '\n' ) );
...
logout = function (req, callback) { this._saml.getLogoutUrl(req, callback); }
...
function validateCallback(err, profile, loggedOut) {
if (err) {
return self.error(err);
}
if (loggedOut) {
req.logout();
if (profile) {
req.samlLogoutRequest = profile;
return self._saml.getLogoutResponseUrl(req, redirectIfSuccess);
}
return self.pass();
}
...
function Strategy() { }
n/a
function Strategy() { }
n/a
authenticate = function (req, options) { throw new Error('Strategy#authenticate must be overridden by subclass'); }
...
### Provide the authentication callback
You need to provide a route corresponding to the `path` configuration parameter given to the strategy:
```javascript
app.post('/login/callback',
passport.authenticate('saml', { failureRedirect: '/', failureFlash
: true }),
function(req, res) {
res.redirect('/');
}
);
```
### Authenticate requests
...