├── README.md ├── SAML.xml ├── package.json └── sharepoint.js /README.md: -------------------------------------------------------------------------------- 1 | # SharePoint client for Node.js 2 | This Node module provides a SharePoint client for Node.js applications. This allows you to access SharePoint 2010 lists and items, using ListData.svc, an OData based REST API for SharePoint 2010. 3 | 4 | The current version is restricted to SharePoint Online, using claims based authentication. 5 | 6 | ## Installation 7 | 8 | Use npm to install the module: 9 | 10 | ```` 11 | > npm install sharepoint 12 | ```` 13 | 14 | ## API 15 | 16 | Due to the asynchrounous nature of Node.js, the SharePoint client requires the use callbacks in requests. See documentation below. 17 | 18 | All callbacks have 2 arguments: err and data: 19 | 20 | ```` 21 | function callback (err, data) { 22 | // err contains an error, if any 23 | // data contains the resulting data 24 | } 25 | ```` 26 | 27 | 28 | ### SP.RestService(site) 29 | An object of this class represents a REST Service client for the specified SharePoint site. 30 | 31 | Example: 32 | 33 | ```` 34 | var client = new SP.RestService('http://oxida.sharepoint.com/teamsite') 35 | ```` 36 | 37 | ### client.signin (username, password, callback) 38 | The signin method performs a claims-based authentication: 39 | 40 | - build a SAML request (using SAML.xml template included in module) 41 | - submit a SAML token request to Microsoft Online Security Token Service 42 | - receive a signed security token 43 | - POST the token to SharePoint Online 44 | - receive FedAuth and rtFa authentication cookies 45 | - store the cookies in client for use in subsequent requests 46 | 47 | Callback is called when authentication is completed. You can wrap all your service requests inside this callback 48 | 49 | Example: 50 | 51 | ```` 52 | client.signin('myname', 'mypassword', function(err,data) { 53 | 54 | // check for errors during login, e.g. invalid credentials 55 | if (err) { 56 | console.log("Error found: ", err); 57 | return; 58 | } 59 | 60 | // start to do authenticated requests here.... 61 | 62 | }) 63 | ```` 64 | 65 | 66 | ### client.metadata(callback) 67 | Return the metadata document for the service ($metadata) 68 | 69 | ```` 70 | var contacts = client.metadata(function(err, data) { 71 | console.log(data); 72 | }); 73 | ```` 74 | 75 | 76 | ### client.list(name) 77 | Return a new List object, which provides get, update and del(ete) operations 78 | 79 | ```` 80 | var contacts = client.list('Contacts'); 81 | ```` 82 | 83 | ### list.get(callback) 84 | Fetch all items from list. 85 | 86 | ```` 87 | contacts.get(function (err, data) { 88 | // data.results contains an array of all items in Contacts list 89 | // data.__count contains the total number items in Contacts list 90 | // Use query {$inlinecount:'allpages'} to request the __count property (see below). 91 | }) 92 | ```` 93 | 94 | ### list.get(id, callback) 95 | Get a single item with id from the list. 96 | 97 | ```` 98 | contacts.get(12, function (err, data) { 99 | // data contains item with Id 12 from Contacts list 100 | }) 101 | ```` 102 | 103 | ### list.get(query, callback) 104 | Query the list using OData query options. 105 | 106 | ```` 107 | contacts.get({$orderby: 'FirstName'}, function (err, data) { 108 | // data contains items from Contacts list, sorted on FirstName. 109 | }) 110 | ```` 111 | Use $inlinecount to request the total count of items in list: 112 | 113 | ```` 114 | // get the first 3 items and return total number of items in Contacts list 115 | contacts.get({$top: 3, $inlinecount:'allpages'}, function (err, data) { 116 | // data contains items from Contacts list, sorted on FirstName. 117 | }) 118 | ```` 119 | 120 | ### list.add(attributes, callback) 121 | Add a new item to the list 122 | 123 | ```` 124 | contacts.add({LastName: 'Picolet', FirstName: 'Emma'}, function (err, data) { 125 | // data contains the new item returned from server. 126 | // data.Id will be the server assigned Id. 127 | }) 128 | ```` 129 | 130 | ### list.update(id, attributes, callback) 131 | Update the attributes for the list item specified by Id. The client performs a partial update: only the attributes specified in the hash are changed. Partial updates require the use of etags, so you need to get the item first before you change it. 132 | 133 | ```` 134 | contacts.get(411, function (err, data) { 135 | 136 | var changes = { 137 | // include the changes that need to be made 138 | LastName: 'Tell', 139 | FirstName: 'William', 140 | 141 | // pass the metadata from the fetched item 142 | // this includes the require etag 143 | __metadata: data.__metadata 144 | } 145 | 146 | contacts.update(411, changes, function () { 147 | // at this point, the change is completed 148 | }) 149 | }) 150 | 151 | ```` 152 | 153 | ### list.del(id, callback) 154 | Delete list item specified by Id from the list. 155 | 156 | ```` 157 | contacts.del(411, function (err, data) { 158 | // at this point deletion is completed. 159 | }) 160 | 161 | ```` 162 | 163 | 164 | ## To Do 165 | 166 | This first version of SharePoint client library allows you to read and write to SharePoint Online lists. 167 | 168 | There's still a lot to do: 169 | 170 | - Implement and improve error handling 171 | - Support for on-premise SharePoint 172 | - Support for other authentication mechanisms (form based, NTLM, ..) 173 | - Support for other SharePoint APIs (SOAP, CSOM). 174 | 175 | -------------------------------------------------------------------------------- /SAML.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue 4 | 5 | http://www.w3.org/2005/08/addressing/anonymous 6 | 7 | https://login.microsoftonline.com/extSTS.srf 8 | 9 | 10 | [username] 11 | [password] 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | [endpoint] 20 | 21 | 22 | http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey 23 | http://schemas.xmlsoap.org/ws/2005/02/trust/Issue 24 | urn:oasis:names:tc:SAML:1.0:assertion 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Luc Stakenborg (http://www.oxida.com)", 3 | "name": "sharepoint", 4 | "description": "SharePoint client for Node.js", 5 | "version": "0.0.5", 6 | "homepage": "https://github.com/lstak/node-sharepoint", 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:lstak/node-sharepoint.git" 10 | }, 11 | "main": "sharepoint.js", 12 | "engines": { 13 | "node": ">=0.6" 14 | }, 15 | 16 | "dependencies": { 17 | "xml2js": ">= 0.0.1" 18 | }, 19 | "devDependencies": {} 20 | } 21 | -------------------------------------------------------------------------------- /sharepoint.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | qs = require('querystring'), 3 | xml2js = require('xml2js'), 4 | http = require('http'), 5 | https = require('https'), 6 | urlparse = require('url').parse, 7 | samlRequestTemplate = fs.readFileSync(__dirname+'/SAML.xml', 'utf8'); 8 | 9 | 10 | var buildSamlRequest = function (params) { 11 | var key, 12 | saml = samlRequestTemplate; 13 | 14 | for (key in params) { 15 | saml = saml.replace('[' + key + ']', params[key]) 16 | } 17 | 18 | return saml; 19 | } 20 | 21 | var parseXml = function (xml, callback) { 22 | var parser = new xml2js.Parser({ 23 | emptyTag: '' // use empty string as value when tag empty 24 | }); 25 | 26 | parser.on('end', function (js) { 27 | callback && callback(js) 28 | }); 29 | 30 | parser.parseString(xml); 31 | }; 32 | 33 | var parseCookie = function (txt) { 34 | var properties = txt.split('; '), 35 | cookie = {}; 36 | 37 | properties.forEach(function (property, index) { 38 | var idx = property.indexOf('='), 39 | name = (idx > 0 ? property.substring(0, idx) : property), 40 | value = (idx > 0 ? property.substring(idx + 1) : undefined); 41 | 42 | if (index == 0) { 43 | cookie.name = name, 44 | cookie.value = value 45 | } else { 46 | cookie[name] = value 47 | } 48 | 49 | }) 50 | 51 | return cookie; 52 | }; 53 | 54 | var parseCookies = function (txts) { 55 | var cookies = [] 56 | 57 | if (txts) { 58 | txts.forEach(function (txt) { 59 | var cookie = parseCookie(txt); 60 | cookies.push(cookie) 61 | }) 62 | }; 63 | 64 | return cookies; 65 | } 66 | 67 | 68 | var getCookie = function (cookies, name) { 69 | var cookie, 70 | i = 0, 71 | len = cookies.length; 72 | 73 | for (; i < len; i++) { 74 | cookie = cookies[i] 75 | if (cookie.name == name) { 76 | return cookie 77 | } 78 | } 79 | 80 | return undefined; 81 | 82 | } 83 | 84 | function requestToken(params, callback) { 85 | var samlRequest = buildSamlRequest({ 86 | username: params.username, 87 | password: params.password, 88 | endpoint: params.endpoint 89 | }); 90 | 91 | var options = { 92 | method: 'POST', 93 | host: params.sts.host, 94 | path: params.sts.path, 95 | headers: { 96 | 'Content-Length': samlRequest.length 97 | } 98 | }; 99 | 100 | 101 | var req = https.request(options, function (res) { 102 | var xml = ''; 103 | 104 | res.setEncoding('utf8'); 105 | res.on('data', function (chunk) { 106 | xml += chunk; 107 | }) 108 | 109 | res.on('end', function () { 110 | 111 | parseXml(xml, function (js) { 112 | 113 | // check for errors 114 | if (js['S:Body']['S:Fault']) { 115 | var error = js['S:Body']['S:Fault']['S:Detail']['psf:error']['psf:internalerror']['psf:text']; 116 | callback(error); 117 | return; 118 | } 119 | 120 | // extract token 121 | var token = js['S:Body']['wst:RequestSecurityTokenResponse']['wst:RequestedSecurityToken']['wsse:BinarySecurityToken']['#']; 122 | 123 | // Now we have the token, we need to submit it to SPO 124 | submitToken({ 125 | token: token, 126 | endpoint: params.endpoint 127 | }, callback) 128 | }) 129 | }) 130 | }); 131 | 132 | req.end(samlRequest); 133 | } 134 | 135 | function submitToken(params, callback) { 136 | var token = params.token, 137 | url = urlparse(params.endpoint), 138 | ssl = (url.protocol == 'https:'); 139 | 140 | var options = { 141 | method: 'POST', 142 | host: url.hostname, 143 | path: url.path, 144 | headers: { 145 | // E accounts require a user agent string 146 | 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)' 147 | } 148 | }; 149 | 150 | var protocol = (ssl ? https : http); 151 | 152 | 153 | 154 | var req = protocol.request(options, function (res) { 155 | 156 | var xml = ''; 157 | res.setEncoding('utf8'); 158 | res.on('data', function (chunk) { 159 | xml += chunk; 160 | }) 161 | 162 | res.on('end', function () { 163 | 164 | var cookies = parseCookies(res.headers['set-cookie']) 165 | 166 | callback(null, { 167 | FedAuth: getCookie(cookies, 'FedAuth').value, 168 | rtFa: getCookie(cookies, 'rtFa').value 169 | }) 170 | }) 171 | }); 172 | 173 | req.end(token); 174 | } 175 | 176 | 177 | function signin(username, password, callback) { 178 | var self = this; 179 | 180 | var options = { 181 | username: username, 182 | password: password, 183 | sts: self.sts, 184 | endpoint: self.url.protocol + '//' + self.url.hostname + self.login 185 | } 186 | 187 | requestToken(options, function (err, data) { 188 | 189 | if (err) { 190 | callback(err); 191 | return; 192 | } 193 | 194 | self.FedAuth = data.FedAuth; 195 | self.rtFa = data.rtFa; 196 | 197 | callback(null, data); 198 | }) 199 | } 200 | 201 | 202 | 203 | function request(options, next) { 204 | 205 | var req_data = options.data || '', 206 | list = options.list, 207 | id = options.id, 208 | query = options.query, 209 | ssl = (this.protocol == 'https:'), 210 | path = this.path + this.odatasvc + list + 211 | (id ? '(' + id + ')' : '') + 212 | (query ? '?' + qs.stringify(query) : ''); 213 | 214 | var req_options = { 215 | method: options.method, 216 | host: this.host, 217 | path: path, 218 | headers: { 219 | 'Accept': options.accept || 'application/json', 220 | 'Content-type': 'application/json', 221 | 'Cookie': 'FedAuth=' + this.FedAuth + '; rtFa=' + this.rtFa, 222 | 'Content-length': req_data.length 223 | } 224 | }; 225 | 226 | // Include If-Match header if etag is specified 227 | if (options.etag) { 228 | req_options.headers['If-Match'] = options.etag; 229 | }; 230 | 231 | //console.log('OPTIONS:', req_options); 232 | //console.log('DATA:', req_data); 233 | 234 | // support for using https 235 | var protocol = (ssl ? https : http); 236 | 237 | var req = protocol.request(req_options, function (res) { 238 | //console.log('STATUS:', res.statusCode); 239 | //console.log('HEADERS:', res.headers); 240 | 241 | 242 | var res_data = ''; 243 | res.setEncoding('utf8'); 244 | res.on('data', function (chunk) { 245 | //console.log('CHUNK:', chunk); 246 | res_data += chunk; 247 | }); 248 | res.on('end', function () { 249 | // if no callback is defined, we're done. 250 | if (!next) return; 251 | 252 | // if data of content-type application/json is return, parse into JS: 253 | if (res_data && (res.headers['content-type'].indexOf('json') > 0)) { 254 | res_data = JSON.parse(res_data).d 255 | } 256 | 257 | if (res_data) { 258 | next(null, res_data) 259 | } 260 | else { 261 | next() 262 | } 263 | }); 264 | }) 265 | 266 | req.end(req_data); 267 | } 268 | 269 | 270 | // the following call to get are allowed: 271 | // get([callback]) - fecth all items from list 272 | // get(id [,callback]) - fetch an item from the list 273 | // get(query [,callback]) - query list 274 | 275 | function get(arg1, arg2) { 276 | var id, query, callback; 277 | 278 | if ('object' == typeof arg1) { 279 | query = arg1, 280 | callback = arg2 281 | } 282 | 283 | if ('string' == typeof arg1 || 'number' == typeof arg1) { 284 | id = arg1, 285 | callback = arg2 286 | } 287 | 288 | if (!arg2) { 289 | callback = arg1 290 | } 291 | 292 | var options = { 293 | list: this.name, 294 | id: id, 295 | query: query, 296 | method: 'GET' 297 | }; 298 | 299 | this.service.request(options, callback); 300 | } 301 | 302 | 303 | 304 | 305 | function add(attributes, next) { 306 | var options = { 307 | list: this.name, 308 | method: 'POST', 309 | data: JSON.stringify(attributes) 310 | }; 311 | 312 | this.service.request(options, next); 313 | } 314 | 315 | function del(id, next) { 316 | var options = { 317 | list: this.name, 318 | id: id, 319 | method: 'DELETE' 320 | }; 321 | 322 | this.service.request(options, next); 323 | } 324 | 325 | 326 | function update(id, attributes, next) { 327 | var options = { 328 | list: this.name, 329 | id: id, 330 | method: 'MERGE', 331 | etag: attributes.__metadata.etag, 332 | data: JSON.stringify(attributes) 333 | }; 334 | 335 | this.service.request(options, next); 336 | } 337 | 338 | 339 | 340 | // convenience method to create a List provided by RestService. 341 | function list(name) { 342 | var list = new SP.List(this, name) 343 | return list; 344 | }; 345 | 346 | // method to fetch a RestService metadata document 347 | function metadata(next) { 348 | var options = { 349 | list: '$metadata', 350 | accept: 'application/xml', 351 | method: 'GET' 352 | }; 353 | 354 | this.request(options, next); 355 | } 356 | 357 | 358 | var SP = {}; 359 | 360 | // constructor for REST service 361 | SP.RestService = function (url) { 362 | this.url = urlparse(url); 363 | this.host = this.url.host; 364 | this.path = this.url.path; 365 | this.protocol = this.url.protocol; 366 | 367 | 368 | // External Security Token Service for SPO 369 | this.sts = { 370 | host: 'login.microsoftonline.com', 371 | path: '/extSTS.srf' 372 | }; 373 | 374 | // Form to submit SAML token 375 | this.login = '/_forms/default.aspx?wa=wsignin1.0'; 376 | 377 | 378 | // SharePoint Odata (REST) service 379 | this.odatasvc = '/_vti_bin/ListData.svc/'; 380 | 381 | }; 382 | 383 | SP.RestService.prototype = { 384 | signin: signin, 385 | list: list, 386 | request: request, 387 | metadata: metadata 388 | }; 389 | 390 | 391 | // Constructor for SP List 392 | SP.List = function (service, name) { 393 | this.service = service 394 | this.name = name 395 | } 396 | 397 | SP.List.prototype = { 398 | get: get, 399 | add: add, 400 | update: update, 401 | del: del 402 | }; 403 | 404 | module.exports = SP; --------------------------------------------------------------------------------