From 8857b80ae0dd7be54d0d731000c9f8edb0434336 Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 26 Apr 2013 08:54:43 -0700 Subject: Bug 867747 - OAuth2 authentication support (particularly for google) r=kgrandon,gaye --- Makefile | 9 +- caldav.js | 644 +++++++++++++++++++++++++++++++++--- lib/caldav/connection.js | 63 ++-- lib/caldav/http/basic_auth.js | 34 ++ lib/caldav/http/index.js | 14 + lib/caldav/http/oauth2.js | 114 +++++++ lib/caldav/index.js | 2 + lib/caldav/oauth2.js | 260 +++++++++++++++ lib/caldav/querystring.js | 82 +++++ lib/caldav/request/abstract.js | 4 + lib/caldav/request/calendar_home.js | 6 +- lib/caldav/xhr.js | 65 ++-- test/caldav/connection_test.js | 64 ++-- test/caldav/http/basic_auth_test.js | 57 ++++ test/caldav/http/oauth2_test.js | 194 +++++++++++ test/caldav/index_test.js | 2 + test/caldav/oauth2_test.js | 258 +++++++++++++++ test/caldav/querystring_test.js | 20 ++ test/caldav/xhr_test.js | 2 +- test/helper.js | 36 +- test/support/fake_xhr.js | 15 +- 21 files changed, 1805 insertions(+), 140 deletions(-) create mode 100644 lib/caldav/http/basic_auth.js create mode 100644 lib/caldav/http/index.js create mode 100644 lib/caldav/http/oauth2.js create mode 100644 lib/caldav/oauth2.js create mode 100644 lib/caldav/querystring.js create mode 100644 test/caldav/http/basic_auth_test.js create mode 100644 test/caldav/http/oauth2_test.js create mode 100644 test/caldav/oauth2_test.js create mode 100644 test/caldav/querystring_test.js 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; } }; @@ -2167,6 +2260,414 @@ function write (chunk) { [Caldav('xhr'), Caldav] : [module, require('./caldav')] )); +(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'); @@ -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)); @@ -3281,6 +3803,20 @@ function write (chunk) { [Caldav('request/resources'), Caldav] : [module, require('../caldav')] )); +(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 = { @@ -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( -- cgit