aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorJames Lal <james@lightsofapollo.com>2013-04-26 08:54:43 -0700
committerJames Lal <james@lightsofapollo.com>2013-05-02 14:01:06 -0700
commit8857b80ae0dd7be54d0d731000c9f8edb0434336 (patch)
tree5e7e4ee8dcc3cb01d48b4b88ab14039bc50cf034 /lib
parent9b6e2c616154f2c20fe6272dca083868c02f98f4 (diff)
downloadjsCalDAV-8857b80ae0dd7be54d0d731000c9f8edb0434336.tar.gz
Bug 867747 - OAuth2 authentication support (particularly for google) r=kgrandon,gaye
Diffstat (limited to 'lib')
-rw-r--r--lib/caldav/connection.js63
-rw-r--r--lib/caldav/http/basic_auth.js34
-rw-r--r--lib/caldav/http/index.js14
-rw-r--r--lib/caldav/http/oauth2.js114
-rw-r--r--lib/caldav/index.js2
-rw-r--r--lib/caldav/oauth2.js260
-rw-r--r--lib/caldav/querystring.js82
-rw-r--r--lib/caldav/request/abstract.js4
-rw-r--r--lib/caldav/request/calendar_home.js6
-rw-r--r--lib/caldav/xhr.js65
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;
}
};