diff options
Diffstat (limited to 'lib/caldav')
-rw-r--r-- | lib/caldav/connection.js | 63 | ||||
-rw-r--r-- | lib/caldav/http/basic_auth.js | 34 | ||||
-rw-r--r-- | lib/caldav/http/index.js | 14 | ||||
-rw-r--r-- | lib/caldav/http/oauth2.js | 114 | ||||
-rw-r--r-- | lib/caldav/index.js | 2 | ||||
-rw-r--r-- | lib/caldav/oauth2.js | 260 | ||||
-rw-r--r-- | lib/caldav/querystring.js | 82 | ||||
-rw-r--r-- | lib/caldav/request/abstract.js | 4 | ||||
-rw-r--r-- | lib/caldav/request/calendar_home.js | 6 | ||||
-rw-r--r-- | lib/caldav/xhr.js | 65 |
10 files changed, 591 insertions, 53 deletions
diff --git a/lib/caldav/connection.js b/lib/caldav/connection.js index 6884217..7262d06 100644 --- a/lib/caldav/connection.js +++ b/lib/caldav/connection.js @@ -30,6 +30,10 @@ } } + var httpHandler = this.httpHandler || 'basic_auth'; + if (typeof(httpHandler) !== 'object') { + this.httpHandler = Caldav.require('http/' + httpHandler); + } } Connection.prototype = { @@ -55,36 +59,49 @@ * @return {Caldav.Xhr} http request set with default options. */ request: function(options) { - if (typeof(options) === 'undefined') { - options = {}; - } - - var copy = {}; - var key; - // copy options - - for (key in options) { - copy[key] = options[key]; + if (options) { + if (options.url && options.url.indexOf('http') !== 0) { + var url = options.url; + if (url.substr(0, 1) !== '/') { + url = '/' + url; + } + options.url = this.domain + url; + } } - if (!copy.user) { - copy.user = this.user; - } + return new this.httpHandler(this, options); + }, - if (!copy.password) { - copy.password = this.password; + /** + * Update properties on this connection and trigger a "update" event. + * + * + * connection.onupdate = function() { + * // do stuff + * }; + * + * connection.update({ + * user: 'foobar' + * }); + * + * + * @param {Object} newProperties to shallow copy onto connection. + */ + update: function(newProperties) { + if (newProperties) { + for (var key in newProperties) { + if (Object.prototype.hasOwnProperty.call(newProperties, key)) { + this[key] = newProperties[key]; + } + } } - if (copy.url && copy.url.indexOf('http') !== 0) { - var url = copy.url; - if (url.substr(0, 1) !== '/') { - url = '/' + url; - } - copy.url = this.domain + url; + if (this.onupdate) { + this.onupdate(); } - return new XHR(copy); - } + return this; + }, }; diff --git a/lib/caldav/http/basic_auth.js b/lib/caldav/http/basic_auth.js new file mode 100644 index 0000000..07f083d --- /dev/null +++ b/lib/caldav/http/basic_auth.js @@ -0,0 +1,34 @@ +(function(module, ns) { + + var XHR = ns.require('xhr'); + + function BasicAuth(connection, options) { + // create a clone of options + var clone = Object.create(null); + + if (typeof(options) !== 'undefined') { + for (var key in options) { + clone[key] = options[key]; + } + } + + clone.password = connection.password || clone.password; + clone.user = connection.user || clone.user; + + XHR.call(this, clone); + } + + BasicAuth.prototype = { + __proto__: XHR.prototype + }; + + + module.exports = BasicAuth; + +}.apply( + this, + (this.Caldav) ? + [Caldav('http/basic_auth'), Caldav] : + [module, require('../caldav')] +)); + diff --git a/lib/caldav/http/index.js b/lib/caldav/http/index.js new file mode 100644 index 0000000..dde939d --- /dev/null +++ b/lib/caldav/http/index.js @@ -0,0 +1,14 @@ +(function(module, ns) { + + module.exports = { + BasicAuth: ns.require('http/basic_auth'), + OAuth2: ns.require('http/oauth2') + }; + +}.apply( + this, + (this.Caldav) ? + [Caldav('http'), Caldav] : + [module, require('../caldav')] +)); + diff --git a/lib/caldav/http/oauth2.js b/lib/caldav/http/oauth2.js new file mode 100644 index 0000000..75e6334 --- /dev/null +++ b/lib/caldav/http/oauth2.js @@ -0,0 +1,114 @@ +(function(module, ns) { + + var XHR = ns.require('xhr'); + var QueryString = ns.require('querystring'); + var Connection = ns.require('connection'); + var OAuth = ns.require('oauth2'); + + /** + * Creates an XHR like object given a connection and a set of options + * (passed directly to the superclass) + * + * @param {Caldav.Connection} connection used for apiCredentials. + * @param {Object} options typical XHR options. + */ + function Oauth2(connection, options) { + if ( + !connection || + !connection.oauth || + ( + !connection.oauth.code && + !connection.oauth.refresh_token + ) + ) { + throw new Error('connection .oauth must have code or refresh_token'); + } + + this.connection = connection; + + this.oauth = + new OAuth(connection.apiCredentials); + + // create a clone of options + var clone = Object.create(null); + + if (typeof(options) !== 'undefined') { + for (var key in options) { + clone[key] = options[key]; + } + } + + XHR.call(this, clone); + } + + Oauth2.prototype = { + __proto__: XHR.prototype, + + _sendXHR: function(xhr) { + xhr.setRequestHeader( + 'Authorization', 'Bearer ' + this.connection.oauth.access_token + ); + + xhr.send(this._serialize()); + return xhr; + }, + + _updateConnection: function(credentials) { + var oauth = this.connection.oauth; + var update = { oauth: credentials }; + + if (oauth.refresh_token && !credentials.refresh_token) + credentials.refresh_token = oauth.refresh_token; + + if (credentials.user) { + update.user = credentials.user; + delete credentials.user; + } + + return this.connection.update(update); + }, + + send: function(callback) { + var xhr = this._buildXHR(callback); + var oauth = this.connection.oauth; + + // everything is fine just send + if (this.oauth.accessTokenValid(oauth)) { + return this._sendXHR(xhr); + } + + var handleTokenUpdates = (function handleTokenUpdates(err, credentials) { + if (err) { + return callback(err); + } + this._updateConnection(credentials); + return this._sendXHR(xhr); + }.bind(this)); + + if (oauth.code) { + this.oauth.authenticateCode(oauth.code, handleTokenUpdates); + + // it should be impossible to have both code and refresh_token + // but we return as a guard + return xhr; + } + + if (oauth.refresh_token) { + this.oauth.refreshToken(oauth.refresh_token, handleTokenUpdates); + return xhr; + } + } + + }; + + + module.exports = Oauth2; + +}.apply( + this, + (this.Caldav) ? + [Caldav('http/oauth2'), Caldav] : + [module, require('../caldav')] +)); + + diff --git a/lib/caldav/index.js b/lib/caldav/index.js index cdce101..636fc18 100644 --- a/lib/caldav/index.js +++ b/lib/caldav/index.js @@ -10,6 +10,8 @@ exports.Request = ns.require('request'); exports.Connection = ns.require('connection'); exports.Resources = ns.require('resources'); + exports.Http = ns.require('http'); + exports.OAuth2 = ns.require('oauth2'); }.apply( this, diff --git a/lib/caldav/oauth2.js b/lib/caldav/oauth2.js new file mode 100644 index 0000000..b41f630 --- /dev/null +++ b/lib/caldav/oauth2.js @@ -0,0 +1,260 @@ +(function(module, ns) { + + var XHR = ns.require('xhr'); + var QueryString = ns.require('querystring'); + + var REQUIRED_CREDENTIALS = [ + 'client_secret', + 'client_id', + 'redirect_uri', + 'url' + ]; + + /** + * Given a string (directly from xhr.responseText usually) format and create + * an oauth authorization server response. + * + * @param {String} resp raw response from http server. + * @return {Object} formatted version of response. + */ + function formatResponse(resp) { + resp = JSON.parse(resp); + + // replace the oauth details + if (resp.access_token) { + resp.issued_at = Date.now(); + } + + return resp; + } + + /** + * Sends XHR object's request and handles common JSON parsing issues. + */ + function sendRequest(xhr, callback) { + return xhr.send(function(err, request) { + if (err) { + return callback(err); + } + + var result; + try { + result = formatResponse(request.responseText); + } catch (e) { + err = e; + } + + callback(err, result, request); + }); + } + + /** + * Private helper for issuing a POST http request the given endpoint. + * The body of the HTTP request is a x-www-form-urlencoded request. + * + * + * @param {String} url endpoint of server. + * @param {Object} requestData object representation of form data. + */ + function post(url, requestData, callback) { + var xhr = new XHR({ + url: url, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: QueryString.stringify(requestData), + method: 'POST', + streaming: false + }); + + return sendRequest(xhr, callback); + } + + /** + * Creates an OAuth authentication handler. The logic here is designed to + * handle the cases after the user initially authenticates and we either have + * a "code" or "refresh_token". + * + * + * var oauthClient = new OAuth( + * { + * url: 'https://accounts.google.com/o/oauth2/token', + * client_secret: '', + * client_id: '', + * redirect_uri: '', + * // optional user_info option + * user_info: { + * url: 'https://www.googleapis.com/oauth2/v3/userinfo', + * field: 'email' + * } + * } + * ); + * + */ + function OAuth(credentials) { + this.apiCredentials = {}; + + for (var key in credentials) { + this.apiCredentials[key] = credentials[key]; + } + + REQUIRED_CREDENTIALS.forEach(function(type) { + if (!(type in this.apiCredentials)) { + throw new Error('.apiCredentials.' + type + ' : must be available.'); + } + }, this); + } + + OAuth.prototype = { + + /** + * Basic API credentials for oauth operations. + * + * Required properties: + * + * - url + * - client_id + * - client_secret + * - redirect_uri + * + * @type {Object} + */ + apiCredentials: null, + + /** + * Private helper for requesting user info... Unlike other methods this + * is unrelated to core rfc6749 functionality. + * + * NOTE: Really brittle as it will not refresh tokens must be called + * directly after authorization with a fresh access_token. + * + * + * @param {Object} oauth result of a previous oauth response + * (must contain valid access_token). + * + * @param {Function} callback called with [err, userProperty]. + */ + _requestUserInfo: function(oauth, callback) { + var apiCredentials = this.apiCredentials; + var url = apiCredentials.user_info.url; + var field = apiCredentials.user_info.field; + var authorization = oauth.token_type + ' ' + oauth.access_token; + + var xhr = new XHR({ + headers: { + Authorization: authorization + }, + url: url, + streaming: false + }); + + sendRequest(xhr, function(err, json) { + if (err) { + return callback(err); + } + + /* json is an object so this should not explode */ + callback(err, json[field]); + }); + }, + + /** + * Given a code from the user sign in flow get the refresh token & + * access_token. + */ + authenticateCode: function(code, callback) { + var apiCredentials = this.apiCredentials; + + if (!code) { + return setTimeout(function() { + callback(new Error('code must be given')); + }); + } + + var self = this; + function handleResponse(err, result) { + if (err) { + return callback(err); + } + + if (!apiCredentials.user_info) { + return callback(null, result); + } + + // attempt fetching user details + self._requestUserInfo(result, function(err, user) { + if (err) { + return callback(err); + } + result.user = user; + callback(null, result); + }); + } + + return post( + apiCredentials.url, + { + code: code, + client_id: apiCredentials.client_id, + client_secret: apiCredentials.client_secret, + redirect_uri: apiCredentials.redirect_uri, + grant_type: 'authorization_code' + }, + handleResponse + ); + }, + + /** + * Refresh api keys and tokens related to those keys. + * + * @param {String} refreshToken token for refreshing oauth credentials + * (refresh_token per rfc6749). + */ + refreshToken: function(refreshToken, callback) { + var apiCredentials = this.apiCredentials; + + if (!refreshToken) { + throw new Error('invalid refresh token given: "' + refreshToken + '"'); + } + + return post( + apiCredentials.url, + { + refresh_token: refreshToken, + client_id: apiCredentials.client_id, + client_secret: apiCredentials.client_secret, + grant_type: 'refresh_token' + }, + callback + ); + }, + + /** + * Soft verification of tokens... Ensures access_token is available and is + * not expired. + * + * @param {Object} oauth details. + * @return {Boolean} true when looks valid. + */ + accessTokenValid: function(oauth) { + return !!( + oauth && + oauth.access_token && + oauth.expires_in && + oauth.issued_at && + (Date.now() < (oauth.issued_at + oauth.expires_in)) + ); + } + + }; + + + module.exports = OAuth; + +}.apply( + this, + (this.Caldav) ? + [Caldav('oauth2'), Caldav] : + [module, require('./caldav')] +)); + + + diff --git a/lib/caldav/querystring.js b/lib/caldav/querystring.js new file mode 100644 index 0000000..bea349a --- /dev/null +++ b/lib/caldav/querystring.js @@ -0,0 +1,82 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Query String Utilities + +(function(module, ns) { + + var QueryString = {}; + + QueryString.escape = function(str) { + return encodeURIComponent(str); + }; + + var stringifyPrimitive = function(v) { + switch (typeof v) { + case 'string': + return v; + + case 'boolean': + return v ? 'true' : 'false'; + + case 'number': + return isFinite(v) ? v : ''; + + default: + return ''; + } + }; + + + QueryString.stringify = QueryString.encode = function(obj, sep, eq, name) { + sep = sep || '&'; + eq = eq || '='; + if (obj === null) { + obj = undefined; + } + + if (typeof obj === 'object') { + return Object.keys(obj).map(function(k) { + var ks = QueryString.escape(stringifyPrimitive(k)) + eq; + if (Array.isArray(obj[k])) { + return obj[k].map(function(v) { + return ks + QueryString.escape(stringifyPrimitive(v)); + }).join(sep); + } else { + return ks + QueryString.escape(stringifyPrimitive(obj[k])); + } + }).join(sep); + + } + + if (!name) return ''; + return QueryString.escape(stringifyPrimitive(name)) + eq + + QueryString.escape(stringifyPrimitive(obj)); + }; + + module.exports = QueryString; + +}.apply( + this, + (this.Caldav) ? + [Caldav('querystring'), Caldav] : + [module, require('./caldav')] +)); diff --git a/lib/caldav/request/abstract.js b/lib/caldav/request/abstract.js index 9edf6fd..4db3883 100644 --- a/lib/caldav/request/abstract.js +++ b/lib/caldav/request/abstract.js @@ -68,6 +68,10 @@ // in the future we may stream data somehow req.send(function xhrResult(err, xhr) { + if (err) { + return callback(err); + } + if (xhr.status > 199 && xhr.status < 300) { // success self.sax.close(); diff --git a/lib/caldav/request/calendar_home.js b/lib/caldav/request/calendar_home.js index a1f8ca6..d0659bb 100644 --- a/lib/caldav/request/calendar_home.js +++ b/lib/caldav/request/calendar_home.js @@ -77,10 +77,10 @@ if (!principal) { principal = findProperty('principal-URL', data, true); } - + if ('unauthenticated' in principal) { - callback(new Errors.UnauthenticatedError()); - } else if (principal.href){ + callback(new Errors.UnauthenticatedError()); + } else if (principal.href) { callback(null, principal.href); } else { callback(new Errors.CaldavHttpError(404)); diff --git a/lib/caldav/xhr.js b/lib/caldav/xhr.js index e88e789..6c8351f 100644 --- a/lib/caldav/xhr.js +++ b/lib/caldav/xhr.js @@ -50,6 +50,7 @@ user: null, password: null, url: null, + streaming: true, headers: {}, data: null, @@ -85,12 +86,7 @@ } }, - /** - * Sends request to server. - * - * @param {Function} callback success/failure handler. - */ - send: function send(callback) { + _buildXHR: function(callback) { var header; if (typeof(callback) === 'undefined') { @@ -116,7 +112,11 @@ } var useMozChunkedText = false; - if (this.globalXhrOptions && this.globalXhrOptions.useMozChunkedText) { + if ( + this.streaming && + this.globalXhrOptions && + this.globalXhrOptions.useMozChunkedText + ) { useMozChunkedText = true; this.xhr.responseType = 'moz-chunked-text'; } @@ -131,24 +131,26 @@ var hasProgressEvents = false; // check for progress event support. - if ('onprogress' in this.xhr) { - hasProgressEvents = true; - var last = 0; - - if (useMozChunkedText) { - this.xhr.onprogress = (function onChunkedProgress(event) { - if (this.ondata) { - this.ondata(this.xhr.responseText); - } - }.bind(this)); - } else { - this.xhr.onprogress = (function onProgress(event) { - var chunk = this.xhr.responseText.substr(last, event.loaded); - last = event.loaded; - if (this.ondata) { - this.ondata(chunk); - } - }.bind(this)); + if (this.streaming) { + if ('onprogress' in this.xhr) { + hasProgressEvents = true; + var last = 0; + + if (useMozChunkedText) { + this.xhr.onprogress = (function onChunkedProgress(event) { + if (this.ondata) { + this.ondata(this.xhr.responseText); + } + }.bind(this)); + } else { + this.xhr.onprogress = (function onProgress(event) { + var chunk = this.xhr.responseText.substr(last, event.loaded); + last = event.loaded; + if (this.ondata) { + this.ondata(chunk); + } + }.bind(this)); + } } } @@ -171,9 +173,18 @@ }.bind(this)); this.waiting = true; - this.xhr.send(this._serialize()); - return this.xhr; + }, + + /** + * Sends request to server. + * + * @param {Function} callback success/failure handler. + */ + send: function send(callback) { + var xhr = this._buildXHR(callback); + xhr.send(this._serialize()); + return xhr; } }; |