aboutsummaryrefslogtreecommitdiffstats
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
parent9b6e2c616154f2c20fe6272dca083868c02f98f4 (diff)
downloadjsCalDAV-8857b80ae0dd7be54d0d731000c9f8edb0434336.tar.gz
Bug 867747 - OAuth2 authentication support (particularly for google) r=kgrandon,gaye
-rw-r--r--Makefile9
-rw-r--r--caldav.js644
-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
-rw-r--r--test/caldav/connection_test.js64
-rw-r--r--test/caldav/http/basic_auth_test.js57
-rw-r--r--test/caldav/http/oauth2_test.js194
-rw-r--r--test/caldav/index_test.js2
-rw-r--r--test/caldav/oauth2_test.js258
-rw-r--r--test/caldav/querystring_test.js20
-rw-r--r--test/caldav/xhr_test.js2
-rw-r--r--test/helper.js36
-rw-r--r--test/support/fake_xhr.js15
21 files changed, 1805 insertions, 140 deletions
diff --git a/Makefile b/Makefile
index a1c500f..15e64d4 100644
--- a/Makefile
+++ b/Makefile
@@ -17,10 +17,14 @@ package: test-agent-config
echo '/* caldav.js - https://github.com/mozilla-b2g/caldav */' >> $(WEB_FILE)
cat $(LIB_ROOT)/caldav.js >> $(WEB_FILE)
cat $(LIB_ROOT)/responder.js >> $(WEB_FILE)
+ cat $(LIB_ROOT)/querystring.js >> $(WEB_FILE)
cat $(LIB_ROOT)/sax.js >> $(WEB_FILE)
cat $(LIB_ROOT)/template.js >> $(WEB_FILE)
cat $(LIB_ROOT)/query_builder.js >> $(WEB_FILE)
cat $(LIB_ROOT)/xhr.js >> $(WEB_FILE)
+ cat $(LIB_ROOT)/oauth2.js >> $(WEB_FILE)
+ cat $(LIB_ROOT)/http/basic_auth.js >> $(WEB_FILE)
+ cat $(LIB_ROOT)/http/oauth2.js >> $(WEB_FILE)
cat $(LIB_ROOT)/connection.js >> $(WEB_FILE)
cat $(LIB_ROOT)/sax/base.js >> $(WEB_FILE)
cat $(LIB_ROOT)/sax/calendar_data_handler.js >> $(WEB_FILE)
@@ -32,13 +36,11 @@ package: test-agent-config
cat $(LIB_ROOT)/request/calendar_query.js >> $(WEB_FILE)
cat $(LIB_ROOT)/request/calendar_home.js >> $(WEB_FILE)
cat $(LIB_ROOT)/request/resources.js >> $(WEB_FILE)
-
+ cat $(LIB_ROOT)/http/index.js >> $(WEB_FILE)
cat $(LIB_ROOT)/request/index.js >> $(WEB_FILE)
cat $(LIB_ROOT)/sax/index.js >> $(WEB_FILE)
-
cat $(LIB_ROOT)/resources/calendar.js >> $(WEB_FILE)
cat $(LIB_ROOT)/resources/index.js >> $(WEB_FILE)
-
cat $(LIB_ROOT)/index.js >> $(WEB_FILE)
test: test-node test-browser
@@ -54,6 +56,7 @@ test-node:
--reporter $(REPORTER) \
--growl test/helper.js \
test/caldav/sax/*_test.js \
+ test/caldav/http/*_test.js \
test/caldav/request/*_test.js \
test/caldav/*_test.js
diff --git a/caldav.js b/caldav.js
index dfe8694..dfb2d70 100644
--- a/caldav.js
+++ b/caldav.js
@@ -1321,6 +1321,88 @@ function write (chunk) {
[Caldav('responder'), Caldav] :
[module, require('./caldav')]
));
+// 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')]
+));
(function(module, ns) {
var Responder = ns.require('responder');
@@ -2032,6 +2114,7 @@ function write (chunk) {
user: null,
password: null,
url: null,
+ streaming: true,
headers: {},
data: null,
@@ -2067,12 +2150,7 @@ function write (chunk) {
}
},
- /**
- * Sends request to server.
- *
- * @param {Function} callback success/failure handler.
- */
- send: function send(callback) {
+ _buildXHR: function(callback) {
var header;
if (typeof(callback) === 'undefined') {
@@ -2098,7 +2176,11 @@ function write (chunk) {
}
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';
}
@@ -2113,24 +2195,26 @@ function write (chunk) {
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));
+ }
}
}
@@ -2153,9 +2237,18 @@ function write (chunk) {
}.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;
}
};
@@ -2170,6 +2263,414 @@ function write (chunk) {
(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')]
+));
+
+
+
+(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')]
+));
+
+(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')]
+));
+
+
+(function(module, ns) {
+
+ var XHR = ns.require('xhr');
/**
* Connection objects contain
@@ -2199,6 +2700,10 @@ function write (chunk) {
}
}
+ var httpHandler = this.httpHandler || 'basic_auth';
+ if (typeof(httpHandler) !== 'object') {
+ this.httpHandler = Caldav.require('http/' + httpHandler);
+ }
}
Connection.prototype = {
@@ -2224,36 +2729,49 @@ function write (chunk) {
* @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;
+ },
};
@@ -2723,6 +3241,10 @@ function write (chunk) {
// 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();
@@ -3142,10 +3664,10 @@ function write (chunk) {
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));
@@ -3284,6 +3806,20 @@ function write (chunk) {
(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')]
+));
+
+(function(module, ns) {
+
+ module.exports = {
Abstract: ns.require('request/abstract'),
CalendarQuery: ns.require('request/calendar_query'),
Propfind: ns.require('request/propfind'),
@@ -3498,6 +4034,8 @@ function write (chunk) {
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/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;
}
};
diff --git a/test/caldav/connection_test.js b/test/caldav/connection_test.js
index fb32e8e..b035072 100644
--- a/test/caldav/connection_test.js
+++ b/test/caldav/connection_test.js
@@ -1,20 +1,25 @@
testSupport.lib('xhr');
testSupport.lib('connection');
+testSupport.lib('http/basic_auth');
+testSupport.lib('http/oauth2');
suite('caldav/connection', function() {
- var subject;
var Connection;
var XHR;
- var user = 'foo';
- var password = 'bar';
- var domain = 'http://foo.com';
+ var BasicAuth;
suiteSetup(function() {
Connection = Caldav.require('connection');
XHR = Caldav.require('xhr');
+ BasicAuth = Caldav.require('http/basic_auth');
});
+ var subject;
+ var user = 'foo';
+ var password = 'bar';
+ var domain = 'http://foo.com';
+
setup(function() {
subject = new Connection({
user: user,
@@ -38,32 +43,53 @@ suite('caldav/connection', function() {
assert.equal(subject.domain, domain, 'should remove trailing slash');
});
-
});
+ suite('#request', function() {
- suite('request', function() {
+ function commonCases() {
+ test('url without domain', function() {
+ var request = subject.request({
+ url: 'bar.json'
+ });
- test('credentails', function() {
- var result = subject.request({
- url: domain
+ // we add slash
+ assert.equal(request.url, domain + '/bar.json');
});
+ }
- assert.instanceOf(result, XHR);
- assert.equal(result.url, domain);
- assert.equal(result.password, password);
- assert.equal(result.user, user);
- });
+ suite('basic auth (default)', function() {
+
+ test('credentails', function() {
+ var result = subject.request({
+ url: domain
+ });
- test('url without domain', function() {
- var request = subject.request({
- url: 'bar.json'
+ assert.instanceOf(result, BasicAuth);
+ assert.equal(result.url, domain);
+ assert.equal(result.password, password);
+ assert.equal(result.user, user);
});
- // we add slash
- assert.equal(request.url, domain + '/bar.json');
+ commonCases();
});
});
+ suite('#update', function() {
+ test('without .onupdate handler', function() {
+ subject.update({ x: true });
+ assert.equal(subject.x, true);
+ });
+
+ test('with handler', function(done) {
+ subject.onupdate = function() {
+ assert.equal(subject.oauth, 'foo');
+ done();
+ };
+
+ subject.update({ oauth: 'foo' });
+ });
+ });
+
});
diff --git a/test/caldav/http/basic_auth_test.js b/test/caldav/http/basic_auth_test.js
new file mode 100644
index 0000000..566de08
--- /dev/null
+++ b/test/caldav/http/basic_auth_test.js
@@ -0,0 +1,57 @@
+testSupport.lib('xhr');
+testSupport.lib('connection');
+testSupport.lib('http/basic_auth');
+testSupport.helper('fake_xhr');
+
+suite('http/basic_auth', function() {
+
+ var XHR;
+ var FakeXhr;
+ var Connection;
+ var BasicAuth;
+
+ suiteSetup(function() {
+ FakeXhr = Caldav.require('support/fake_xhr');
+ XHR = Caldav.require('xhr');
+ Connection = Caldav.require('connection');
+ BasicAuth = Caldav.require('http/basic_auth');
+ });
+
+ var subject;
+ var connection;
+ var url = 'http://foo.com/bar';
+
+ setup(function() {
+ connection = new Connection({
+ user: 'jlal',
+ password: 'foo',
+ domain: 'google.com'
+ });
+
+ subject = new BasicAuth(connection, {
+ url: url,
+ xhrClass: FakeXhr
+ });
+ });
+
+ test('initialization', function() {
+ assert.instanceOf(subject, XHR);
+ assert.equal(subject.url, url);
+ });
+
+ test('#send', function() {
+ var xhr = subject.send();
+
+ assert.deepEqual(
+ xhr.openArgs,
+ [
+ 'GET',
+ url,
+ subject.async,
+ connection.user,
+ connection.password
+ ]
+ );
+ });
+
+});
diff --git a/test/caldav/http/oauth2_test.js b/test/caldav/http/oauth2_test.js
new file mode 100644
index 0000000..809dfc0
--- /dev/null
+++ b/test/caldav/http/oauth2_test.js
@@ -0,0 +1,194 @@
+testSupport.lib('xhr');
+testSupport.lib('connection');
+testSupport.lib('querystring');
+testSupport.lib('oauth2');
+testSupport.lib('http/oauth2');
+testSupport.helper('fake_xhr');
+
+suite('http/oauth2', function() {
+ var XHR;
+ var FakeXhr;
+ var Connection;
+ var GoogleOauth;
+ var OAuth;
+ var QueryString;
+
+ suiteSetup(function() {
+ FakeXhr = Caldav.require('support/fake_xhr');
+ XHR = Caldav.require('xhr');
+ Connection = Caldav.require('connection');
+ GoogleOauth = Caldav.require('http/oauth2');
+ QueryString = Caldav.require('querystring');
+ OAuth = Caldav.require('oauth2');
+ });
+
+ var subject;
+ var connection;
+
+ setup(function() {
+ connection = new Connection({
+ domain: 'google.com',
+ oauth: { code: 'xxx' },
+ apiCredentials: {
+ url: 'http://foobar.com/',
+ client_id: 'client_id',
+ client_secret: 'client_secret',
+ redirect_uri: 'redirect_uri'
+ }
+ });
+
+ subject = new GoogleOauth(connection, {
+ xhrClass: FakeXhr,
+ url: 'http://bar.com'
+ });
+ });
+
+ test('initialization', function() {
+ assert.instanceOf(subject, XHR);
+ assert.deepEqual(subject.oauth.apiCredentials, connection.apiCredentials);
+ });
+
+ test('without oauth code/refresh_token', function() {
+ connection.oauth = {};
+ assert.throws(function() {
+ new GoogleOauth(connection, {});
+ }, /oauth/);
+ });
+
+ suite('.send', function() {
+ testSupport.mock.useFakeXHR();
+
+ var updatesConnection;
+ setup(function() {
+ updatesConnection = false;
+ connection.onupdate = function() {
+ updatesConnection = true;
+ };
+ });
+
+ function buildRequest() {
+ return new GoogleOauth(connection, {
+ url: 'foo/bar',
+ xhrClass: FakeXhr
+ });
+ }
+
+ function buildResponse(data) {
+ var result = {
+ issued_at: Date.now(),
+ expires_in: 3600,
+ access_token: 'access_token',
+ token_type: 'Bearer'
+ };
+
+ var key;
+ for (key in data) {
+ result[key] = data[key];
+ }
+
+ return result;
+ }
+
+ var request;
+ var response;
+
+ suite('with code', function() {
+ setup(function() {
+ response = buildResponse({
+ refresh_token: 'refresh',
+ user: 'gotuser'
+ });
+ request = buildRequest();
+ });
+
+ test('fetches access_token then sends', function(done) {
+ var isComplete = false;
+ var xhr;
+
+ subject.oauth.authenticateCode = function(code, callback) {
+ assert.equal(code, connection.oauth.code, 'sends correct code');
+ setTimeout(function() {
+ assert.ok(!xhr.sendArgs, 'has not sent request yet');
+ callback(null, response);
+ assert.ok(updatesConnection, 'sends connection update event');
+ assert.deepEqual(connection.oauth, response, 'updates connection');
+ assert.equal(connection.user, 'gotuser', 'updates user');
+
+ assert.ok(xhr.sendArgs, 'sent request');
+ isComplete = true;
+ xhr.respond();
+ });
+ };
+
+ xhr = subject.send(function() {
+ assert.ok(isComplete, 'is complete');
+ done();
+ });
+ });
+ });
+
+ suite('with expired access_token', function() {
+
+ var expectedOauth;
+ setup(function() {
+ // no refresh_token intentionally
+ response = buildResponse();
+
+ connection.oauth = buildResponse({
+ issued_at: Date.now() - 10000,
+ expires_in: 3600,
+ refresh_token: 'refresh_me'
+ });
+
+ request = buildRequest();
+
+ expectedOauth = {};
+ for (var key in response) {
+ expectedOauth[key] = response[key];
+ }
+
+ expectedOauth.refresh_token = connection.oauth.refresh_token;
+ });
+
+ test('refreshes access_token', function(done) {
+ var isComplete = false;
+ var xhr;
+
+ subject.oauth.refreshToken = function(refreshToken, callback) {
+ assert.equal(
+ refreshToken,
+ connection.oauth.refresh_token,
+ 'sends correct refresh token'
+ );
+
+
+ setTimeout(function() {
+ assert.ok(!xhr.sendArgs, 'has not sent request yet');
+ callback(null, response);
+ assert.ok(
+ updatesConnection, 'sends connection update event'
+ );
+
+ assert.deepEqual(
+ connection.oauth, expectedOauth, 'updates connection'
+ );
+
+ assert.ok(xhr.sendArgs, 'sent request');
+ isComplete = true;
+ xhr.respond();
+ });
+ };
+
+ xhr = subject.send(function() {
+ assert.ok(isComplete, 'is complete');
+ done();
+ });
+
+ });
+ });
+
+ suite('with 401 response', function() {
+ });
+ });
+
+});
diff --git a/test/caldav/index_test.js b/test/caldav/index_test.js
index 59012a4..baad822 100644
--- a/test/caldav/index_test.js
+++ b/test/caldav/index_test.js
@@ -31,6 +31,8 @@ suite('caldav', function() {
assert.ok(root.Connection, 'Caldav.Connection');
assert.ok(root.Resources, 'Caldav.Resources');
assert.ok(root.Resources.Calendar, 'Calendar.Resources.Calendar');
+ assert.ok(root.OAuth2, 'OAuth2');
+ assert.ok(root.Http, 'Http');
});
});
diff --git a/test/caldav/oauth2_test.js b/test/caldav/oauth2_test.js
new file mode 100644
index 0000000..9c3024c
--- /dev/null
+++ b/test/caldav/oauth2_test.js
@@ -0,0 +1,258 @@
+testSupport.lib('querystring');
+testSupport.lib('xhr');
+testSupport.lib('oauth2');
+testSupport.helper('fake_xhr');
+
+suite('oauth', function() {
+ var XHR;
+ var FakeXhr;
+ var OAuth;
+ var QueryString;
+
+ suiteSetup(function() {
+ FakeXhr = Caldav.require('support/fake_xhr');
+ XHR = Caldav.require('xhr');
+ QueryString = Caldav.require('querystring');
+ OAuth = Caldav.require('oauth2');
+ });
+
+ function mockTime() {
+ setup(function() {
+ this.clock = this.sinon.useFakeTimers();
+ });
+
+ teardown(function() {
+ this.clock.restore();
+ });
+ }
+
+ var subject;
+ var apiCredentials = {
+ url: 'http://foobar.com/',
+ client_id: 'client_id',
+ client_secret: 'client_secret',
+ redirect_uri: 'redirect_uri'
+ };
+
+ setup(function() {
+ subject = new OAuth(apiCredentials);
+ });
+
+ test('initialization', function() {
+ assert.deepEqual(subject.apiCredentials, apiCredentials);
+ });
+
+ test('invalid credentials', function() {
+ assert.throws(function() {
+ new OAuth({ foo: 'bar' });
+ }, /apiCredentials/);
+ });
+
+ suite('#authoriztionCode', function() {
+ testSupport.mock.useFakeXHR();
+
+ var code = 'codexxx';
+
+ test('without code', function(done) {
+ subject.authenticateCode(null, function(err) {
+ assert.instanceOf(err, Error, 'sends an error');
+ done();
+ });
+ });
+
+ suite('success', function() {
+ mockTime();
+
+ var response = {
+ access_token: 'token',
+ refresh_token: 'refresh',
+ expires_in: 3600,
+ token_type: 'Bearer'
+ };
+
+ var expectedResponse;
+ var expectedRequest;
+
+ setup(function() {
+ // copy expected properties over
+ expectedResponse = {
+ issued_at: Date.now()
+ };
+
+ for (var key in response) {
+ expectedResponse[key] = response[key];
+ }
+
+ // expected post data
+ expectedRequest = QueryString.stringify({
+ code: code,
+ client_id: subject.apiCredentials.client_id,
+ client_secret: subject.apiCredentials.client_secret,
+ redirect_uri: subject.apiCredentials.redirect_uri,
+ grant_type: 'authorization_code'
+ });
+ });
+
+ test('sending request', function(done) {
+ var isComplete = false;
+ var xhr = subject.authenticateCode(code, function(err, result) {
+ assert.isNull(err);
+
+ assert.isTrue(isComplete, 'completed assertions');
+ assert.deepEqual(result, expectedResponse);
+ done();
+ });
+
+
+ // verify xhr does the right thing
+ assert.equal(xhr.sendArgs[0], expectedRequest, 'sends correct params');
+ assert.equal(xhr.openArgs[0], 'POST', 'is HTTP post verb');
+ assert.equal(xhr.openArgs[1], apiCredentials.url, 'opened with url');
+ isComplete = true;
+
+ xhr.respond(
+ JSON.stringify(response),
+ 200,
+ { 'Content-Type': 'application/json' }
+ );
+ });
+
+ test('with username_info', function(done) {
+ var userInfoData = { email: 'myfooba.com' };
+ var userInfo = subject.apiCredentials.user_info = {
+ url: 'http://google.com/',
+ field: 'email'
+ };
+
+ var isComplete = false;
+ var xhr = subject.authenticateCode(code, function(err, data) {
+ assert.isNull(err, 'no error');
+ assert.ok(isComplete, 'completed testing');
+ expectedResponse.user = userInfoData.email;
+ assert.deepEqual(data, expectedResponse);
+ done();
+ });
+
+ xhr.respond(
+ JSON.stringify(response),
+ 200,
+ { 'Content-Type': 'application/json' }
+ );
+
+ var userInfoXhr =
+ FakeXhr.instances[FakeXhr.instances.length - 1];
+
+ assert.notEqual(userInfoXhr, xhr, 'issued userinfo');
+
+ assert.equal(
+ userInfoXhr.openArgs[1],
+ userInfo.url
+ );
+
+ assert.equal(
+ userInfoXhr.headers['Authorization'],
+ response.token_type + ' ' + response.access_token,
+ 'sets access token'
+ );
+
+ isComplete = true;
+
+ userInfoXhr.respond(
+ JSON.stringify(userInfoData),
+ 200,
+ { 'Content-Type': 'application/json' }
+ );
+ });
+
+ });
+ });
+
+ suite('refreshToken', function() {
+ test('without .refresh_token', function() {
+ assert.throws(function() {
+ subject.refreshToken(null, function() {});
+ }, /token/);
+ });
+
+ suite('success', function() {
+ testSupport.mock.useFakeXHR();
+ mockTime();
+
+ var refreshToken = 'mytokenfoo';
+ var response = {
+ access_token: 'newcode',
+ expires_in: 3600,
+ token_type: 'Bearer'
+ };
+
+ var expectedResponse;
+ var expectedRequest;
+ setup(function() {
+ expectedResponse = {
+ access_token: response.access_token,
+ expires_in: response.expires_in,
+ token_type: response.token_type,
+ issued_at: Date.now()
+ };
+
+ expectedRequest = QueryString.stringify({
+ refresh_token: refreshToken,
+ client_id: subject.apiCredentials.client_id,
+ client_secret: subject.apiCredentials.client_secret,
+ grant_type: 'refresh_token'
+ });
+ });
+
+
+ test('send request', function(done) {
+ var isComplete = false;
+ var xhr = subject.refreshToken(refreshToken, function(err, result) {
+ assert.isNull(err);
+ assert.isTrue(isComplete, 'assertions complete');
+ assert.deepEqual(result, expectedResponse);
+ done();
+ });
+
+ assert.deepEqual(xhr.sendArgs[0], expectedRequest, 'sent formdata');
+ assert.equal(xhr.openArgs[1], apiCredentials.url, 'opened with url');
+ isComplete = true;
+
+ xhr.respond(
+ JSON.stringify(response),
+ 200,
+ { 'Content-Type': 'application/json' }
+ );
+ });
+ });
+ });
+
+ suite('#accessTokenValid', function() {
+ test('no access_token', function() {
+ assert.isFalse(subject.accessTokenValid({ code: 'xxx' }));
+ });
+
+ test('access_token present but time invalid', function() {
+ var oauth = {
+ access_token: 'xxx',
+ expires_in: 3600,
+ issued_at: Date.now() - 3700
+ };
+
+ assert.isFalse(subject.accessTokenValid(oauth));
+ });
+
+
+ test('access_token present and not expired', function() {
+ var oauth = {
+ access_token: 'xxx',
+ expires_in: 3600,
+ issued_at: Date.now()
+ };
+
+ assert.isTrue(subject.accessTokenValid(oauth));
+ });
+ });
+
+
+});
+
diff --git a/test/caldav/querystring_test.js b/test/caldav/querystring_test.js
new file mode 100644
index 0000000..e614b6f
--- /dev/null
+++ b/test/caldav/querystring_test.js
@@ -0,0 +1,20 @@
+testSupport.lib('querystring');
+
+suite('caldav/querystring', function() {
+ var QueryString;
+
+ suiteSetup(function() {
+ QueryString = Caldav.require('querystring');
+ });
+
+ /*
+ * quick sanity check we just copied node's version so we expect it to work.
+ */
+ test('stringify', function() {
+ var input = { foo: 'bar', baz: 'qux' };
+ var expected = 'foo=bar&baz=qux';
+
+ assert.equal(QueryString.stringify(input), expected);
+ });
+
+});
diff --git a/test/caldav/xhr_test.js b/test/caldav/xhr_test.js
index a3e6b66..6e93337 100644
--- a/test/caldav/xhr_test.js
+++ b/test/caldav/xhr_test.js
@@ -1,7 +1,7 @@
testSupport.lib('xhr');
testSupport.helper('fake_xhr');
-suite('webacls/xhr', function() {
+suite('xhr', function() {
var subject,
Xhr,
FakeXhr;
diff --git a/test/helper.js b/test/helper.js
index 2a8c772..5cf8d5f 100644
--- a/test/helper.js
+++ b/test/helper.js
@@ -23,7 +23,7 @@
}
requireBak.apply(this, arguments);
- }
+ };
}
/* cross require */
@@ -45,7 +45,7 @@
} else {
window.require(file, callback);
}
- }
+ };
/* sinon */
if (testSupport.isNode) {
@@ -103,7 +103,7 @@
cb(null, xhr.responseText);
}
}
- }
+ };
xhr.send(null);
}
};
@@ -121,6 +121,26 @@
};
testSupport.mock = {
+ useFakeXHR: function() {
+ testSupport.helper('fake_xhr');
+ testSupport.lib('xhr');
+
+ var realXHR;
+ var XHR;
+ var FakeXhr;
+
+ suiteSetup(function() {
+ XHR = Caldav.require('xhr');
+ FakeXhr = Caldav.require('support/fake_xhr');
+
+ realXHR = XHR.prototype.xhrClass;
+ XHR.prototype.xhrClass = FakeXhr;
+ });
+
+ suiteTeardown(function() {
+ XHR.prototype.xhrClass = realXHR;
+ });
+ },
/**
* Mocks out a method
@@ -165,7 +185,7 @@
testSupport.helper = function(lib) {
testSupport.require('/test/support/' + lib);
- }
+ };
Caldav = require('../lib/caldav/caldav.js');
@@ -179,12 +199,15 @@
return require(path);
}
return oldRequire(path);
- }
+ };
}
+ /* since we have global mocks easier to just include these globally */
requireRequest = function(callback) {
testSupport.lib('responder');
- testSupport.lib('xhr');
+ testSupport.lib('oauth2');
+ testSupport.lib('http/basic_auth');
+ testSupport.lib('http/oauth2');
testSupport.lib('connection');
testSupport.lib('sax');
testSupport.lib('sax/base');
@@ -192,7 +215,6 @@
testSupport.lib('request/errors');
testSupport.lib('request/abstract');
testSupport.lib('template');
- testSupport.helper('fake_xhr');
testSupport.lib('request/propfind');
//in the future we need a callback for browser support.
diff --git a/test/support/fake_xhr.js b/test/support/fake_xhr.js
index 0ffcff8..3f6cea0 100644
--- a/test/support/fake_xhr.js
+++ b/test/support/fake_xhr.js
@@ -1,4 +1,5 @@
(function(module) {
+ console.log('I HAZ LOADED');
function FakeXhr() {
this.openArgs = null;
@@ -14,7 +15,7 @@
FakeXhr.prototype = {
open: function() {
- this.openArgs = arguments;
+ this.openArgs = Array.prototype.slice.call(arguments);
},
getResponseHeader: function(key) {
@@ -26,18 +27,24 @@
},
send: function() {
- this.sendArgs = arguments;
+ this.sendArgs = Array.prototype.slice.call(arguments);
},
- respond: function(data, code) {
+ respond: function(data, code, headers) {
+ if (headers) {
+ this.responseHeaders = headers;
+ } else {
+ this.responseHeaders['content-type'] = 'text/xml';
+ }
+
this.readyState = 4;
- this.responseHeaders['content-type'] = 'text/xml';
this.responseText = data;
this.status = code || 200;
this.onreadystatechange();
}
};
+ console.log('EXPORTS ME', FakeXhr);
module.exports = FakeXhr;
}.apply(