/
saml.js
303 lines (241 loc) · 10.9 KB
/
saml.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
// credits to: https://github.com/bergie/passport-saml
var xml2js = require('xml2js');
var xmlCrypto = require('xml-crypto');
var crypto = require('crypto');
var xmldom = require('xmldom');
var xpath = require('xpath');
var cryptiles = require('cryptiles');
var ELEMENT_NODE = 1;
var SAML = function (options) {
this.options = options;
if (this.options.thumbprint) {
this.options.thumbprints = (this.options.thumbprints || []).concat([this.options.thumbprint]);
}
if (!this.options.cert && (!this.options.thumbprints || this.options.thumbprints.length === 0)) {
throw new Error('You should set either a base64 encoded certificate or the thumbprints of the signing certificates');
}
this.options.checkExpiration = (typeof this.options.checkExpiration !== 'undefined') ? this.options.checkExpiration : true;
this.options.checkAudience = (typeof this.options.checkAudience !== 'undefined') ? this.options.checkAudience : true;
};
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;
};
SAML.prototype.validateSignature = function (xml, options, callback) {
var self = this;
if (typeof xml === 'string') xml = new xmldom.DOMParser().parseFromString(xml);
var signaturePath = this.options.signaturePath || options.signaturePath;
var signature = xpath.select(signaturePath, xml)[0];
if (!signature)
return callback(new Error('Signature is missing (xpath: ' + signaturePath + ')'));
var sig = new xmlCrypto.SignedXml(null, { idAttribute: 'AssertionID' });
sig.keyInfoProvider = {
getKeyInfo: function (key) {
return "<X509Data></X509Data>";
},
getKey: function (keyInfo) {
//If there's no embedded signing cert, use the configured cert through options
if(keyInfo.length===0){
if(!options.cert) throw new Error('options.cert must be specified for SAMLResponses with no embedded signing certificate');
return self.certToPEM(options.cert);
}
//If there's an embedded signature and thumprints are provided check that
if (options.thumbprints && options.thumbprints.length > 0) {
var embeddedSignature = keyInfo[0].getElementsByTagNameNS("http://www.w3.org/2000/09/xmldsig#", "X509Certificate");
if (embeddedSignature.length > 0) {
var base64cer = embeddedSignature[0].firstChild.toString();
var shasum = crypto.createHash('sha1');
var der = new Buffer(base64cer, 'base64').toString('binary');
shasum.update(der);
self.calculatedThumbprint = shasum.digest('hex');
return self.certToPEM(base64cer);
}
}
// If there's an embedded signature, but no thumprints are supplied, use options.cert
// either options.cert or options.thumbprints must be specified so at this point there
// must be an options.cert
return self.certToPEM(options.cert);
}
};
var valid;
try {
sig.loadSignature(signature.toString());
valid = sig.checkSignature(xml.toString());
} catch (e) {
return callback(e);
}
if (!valid) {
return callback(new Error('Signature check errors: ' + sig.validationErrors));
}
if (options.cert) {
return callback();
}
if (options.thumbprints) {
var valid_thumbprint = options.thumbprints.some(function (thumbprint) {
return self.calculatedThumbprint.toUpperCase() === thumbprint.toUpperCase();
});
if (!valid_thumbprint) {
return callback(new Error('Invalid thumbprint (configured: ' + options.thumbprints.join(', ').toUpperCase() + '. calculated: ' + this.calculatedThumbprint.toUpperCase() + ')' ));
}
return callback();
}
};
SAML.prototype.validateExpiration = function (samlAssertion, version) {
var conditions = xpath.select("//*[local-name(.)='Conditions']", samlAssertion);
if (!conditions || conditions.length === 0) return false;
var notBefore = new Date(conditions[0].getAttribute('NotBefore'));
notBefore = notBefore.setMinutes(notBefore.getMinutes() - 10); // 10 minutes clock skew
var notOnOrAfter = new Date(conditions[0].getAttribute('NotOnOrAfter'));
notOnOrAfter = notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 10); // 10 minutes clock skew
var now = new Date();
if (now < notBefore || now > notOnOrAfter)
return false;
return true;
};
SAML.prototype.validateAudience = function (samlAssertion, realm, version) {
var audience;
if (version === '2.0') {
audience = xpath.select("//*[local-name(.)='Conditions']/*[local-name(.)='AudienceRestriction']/*[local-name(.)='Audience']", samlAssertion);
} else {
audience = xpath.select("//*[local-name(.)='Conditions']/*[local-name(.)='AudienceRestrictionCondition']/*[local-name(.)='Audience']", samlAssertion);
}
if (!audience || audience.length === 0) return false;
return cryptiles.fixedTimeComparison(audience[0].textContent, realm);
};
SAML.prototype.parseAttributes = function (samlAssertion, version) {
function getAttributes(samlAssertion) {
var attributes = xpath.select("//*[local-name(.)='AttributeStatement']/*[local-name(.)='Attribute']", samlAssertion);
return attributes;
}
function getSessionIndex(samlAssertion) {
var authnStatement = xpath.select("//*[local-name(.)='AuthnStatement']", samlAssertion);
var sessionIndex = authnStatement.length > 0 && authnStatement[0].attributes.length > 0 ?
authnStatement[0].getAttribute('SessionIndex') : undefined;
return sessionIndex || undefined;
}
function getNameID20(samlAssertion) {
var nameId = xpath.select("//*[local-name(.)='Subject']/*[local-name(.)='NameID']", samlAssertion);
if (nameId.length === 0) return;
var element = nameId[0];
var result = {
value: element.textContent,
};
['NameQualifier',
'SPNameQualifier',
'Format',
'SPProvidedID'].forEach(function(key) {
var value = element.getAttribute(key);
if (!value) return;
result[key] = element.getAttribute(key);
});
return result;
}
function getNameID11(samlAssertion) {
var nameId = xpath.select("//*[local-name(.)='AttributeStatement']/*[local-name(.)='Subject']/*[local-name(.)='NameIdentifier']", samlAssertion);
if (nameId.length === 0) return;
return nameId[0].textContent;
}
function getAttributeValues(attribute) {
if (!attribute || attribute.childNodes.length === 0) return;
var attributeValues = [];
for (var i = 0; i<attribute.childNodes.length; i++) {
if (attribute.childNodes[i].nodeType !== ELEMENT_NODE) continue;
attributeValues.push(attribute.childNodes[i].textContent);
}
if (attributeValues.length === 1) return attributeValues[0];
return attributeValues;
}
function getAuthContext20(samlAssertion) {
var authnContext = xpath.select("//*[local-name(.)='AuthnStatement']/*[local-name(.)='AuthnContext']/*[local-name(.)='AuthnContextClassRef']", samlAssertion);
if (authnContext.length === 0) return;
return authnContext[0].textContent;
}
var profile = {};
var nameId;
var authContext;
var attributes = getAttributes(samlAssertion);
profile.sessionIndex = getSessionIndex(samlAssertion);
if (version === '2.0') {
for (var index in attributes) {
var attribute = attributes[index];
var value = getAttributeValues(attribute);
profile[attribute.getAttribute('Name')] = value;
}
nameId = getNameID20(samlAssertion);
if (nameId) {
profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] = nameId.value;
if(Object.keys(nameId).length > 1) {
profile['nameIdAttributes'] = nameId;
}
}
authContext = getAuthContext20(samlAssertion);
if (authContext) {
profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod'] = authContext;
}
} else {
if (attributes) {
for (var index2 in attributes) {
var attribute2 = attributes[index2];
var value2 = getAttributeValues(attribute2);
profile[attribute2.getAttribute('AttributeNamespace') + '/' + attribute2.getAttribute('AttributeName')] = value2;
}
}
nameId = getNameID11(samlAssertion);
if (nameId) {
profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] = typeof nameId === 'string' ? nameId : nameId['#'];
}
}
return profile;
};
SAML.prototype.validateSamlAssertion = function (samlAssertion, callback) {
var self = this;
if (typeof samlAssertion === 'string')
samlAssertion = new xmldom.DOMParser().parseFromString(samlAssertion);
self.validateSignature(samlAssertion, {
cert: self.options.cert,
thumbprints: self.options.thumbprints,
signaturePath: "//*[local-name(.)='Assertion']/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']" }, function(err) {
if (err) return callback(err);
self.parseAssertion(samlAssertion, callback);
});
};
SAML.prototype.parseAssertion = function(samlAssertion, callback) {
var self = this;
if (self.options.extractSAMLAssertion){
samlAssertion = self.options.extractSAMLAssertion(samlAssertion);
}
if (typeof samlAssertion === 'string')
samlAssertion = new xmldom.DOMParser().parseFromString(samlAssertion).documentElement;
if (!samlAssertion.getAttribute)
samlAssertion = samlAssertion.documentElement;
var version;
if (samlAssertion.getAttribute('MajorVersion') === '1')
version = '1.1';
else if (samlAssertion.getAttribute('Version') === '2.0')
version = '2.0';
else
return callback(new Error('SAML Assertion version not supported'), null);
if (self.options.checkExpiration && !self.validateExpiration(samlAssertion, version)) {
return callback(new Error('assertion has expired.'), null);
}
if (self.options.checkAudience && !self.validateAudience(samlAssertion, self.options.realm, version)) {
console.log('Audience is invalid. Configured: ' + self.options.realm);
return callback(new Error('Audience is invalid. Configured: ' + self.options.realm), null);
}
var profile = self.parseAttributes(samlAssertion, version);
var issuer;
if (version === '2.0') {
var issuerNode = xpath.select("//*[local-name(.)='Issuer']", samlAssertion);
if (issuerNode.length > 0) issuer = issuerNode[0].textContent;
} else {
issuer = samlAssertion.getAttribute('Issuer');
}
profile.issuer = issuer;
if (!profile.email && profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']) {
profile.email = profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'];
}
callback(null, profile);
};
exports.SAML = SAML;