From 97b714b6705ba7f719febe9ca7c814bf768207f8 Mon Sep 17 00:00:00 2001 From: vladikoff Date: Mon, 4 Aug 2014 20:49:24 -0700 Subject: [PATCH] feat(oauth): Add WebChannel support --- app/scripts/lib/channels.js | 32 ++++++++- app/scripts/lib/channels/web.js | 83 +++++++++++++++++++++++ app/scripts/lib/session.js | 3 +- app/scripts/views/mixins/service-mixin.js | 74 ++++++++++++++++---- app/scripts/views/ready.js | 15 +++- app/tests/mocks/window.js | 6 +- app/tests/spec/lib/channels/web.js | 69 +++++++++++++++++++ app/tests/test_start.js | 1 + 8 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 app/scripts/lib/channels/web.js create mode 100644 app/tests/spec/lib/channels/web.js diff --git a/app/scripts/lib/channels.js b/app/scripts/lib/channels.js index 228c7b35a3..4b1a005f45 100644 --- a/app/scripts/lib/channels.js +++ b/app/scripts/lib/channels.js @@ -10,8 +10,10 @@ define([ 'lib/promise', 'lib/channels/null', 'lib/channels/fx-desktop', - 'lib/channels/redirect' -], function (Session, p, NullChannel, FxDesktopChannel, RedirectChannel) { + 'lib/channels/redirect', + 'lib/channels/web', + 'lib/url' +], function (Session, p, NullChannel, FxDesktopChannel, RedirectChannel, WebChannel, Url) { 'use strict'; return { @@ -24,15 +26,21 @@ define([ */ get: function (options) { options = options || {}; + var context = options.window || window; if (options.channel) { return options.channel; } var channel; + // try to get the webChannelId from Session and URL params + var webChannelId = this.getWebChannelId(context); if (Session.isDesktopContext()) { channel = new FxDesktopChannel(); + } else if (webChannelId) { + // use WebChannel if "webChannelId" is set + channel = new WebChannel(webChannelId); } else if (Session.isOAuth()) { // By default, all OAuth communication happens via redirects. channel = new RedirectChannel(); @@ -41,7 +49,7 @@ define([ } channel.init({ - window: options.window || window + window: context }); return channel; @@ -72,6 +80,24 @@ define([ }); return deferred.promise; + }, + + /** + * Returns the WebChannel id if available + */ + getWebChannelId: function (context) { + var id = null; + // check given window context + if (context && context.location) { + id = Url.searchParam('webChannelId', context.location.search); + } + + // fallback to session if context cannot find the id + if (! id && Session.oauth) { + id = Session.oauth.webChannelId; + } + + return id; } }; }); diff --git a/app/scripts/lib/channels/web.js b/app/scripts/lib/channels/web.js new file mode 100644 index 0000000000..e922125f6e --- /dev/null +++ b/app/scripts/lib/channels/web.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// A channel that completes the OAuth flow using Firefox WebChannel events +// https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/WebChannel.jsm +// https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/FxAccountsOAuthClient.jsm + +'use strict'; + +define([ + 'underscore', + 'lib/url', + 'lib/channels/base' +], function (_, Url, BaseChannel) { + + function noOp() { + // it's a noOp, nothing to do. + } + + function WebChannel(id) { + if (! id) { + throw new Error('WebChannel must have an id'); + } + + this.id = id; + } + + _.extend(WebChannel.prototype, new BaseChannel(), { + init: function (options) { + options = options || {}; + + this._window = options.window || window; + }, + + /** + * Creates a new WebChannelMessageToChrome CustomEvent and dispatches it. + * The event is received by a content script in Firefox + * + * @param {String} command + * Command name + * @param {Object} data + * Message Object + * @param {Function} [done] + * Optional callback function + */ + send: function (command, data, done) { + done = done || noOp; + + try { + // Browsers can blow up dispatching the event. + // Ignore the blowups and return without retrying. + var event = this.createEvent(command, data); + this._window.dispatchEvent(event); + } catch (e) { + return done && done(e); + } + + done(null); + }, + /** + * Create a WebChannel compatible custom event + * @param {String} command + * Command name + * @param {Object} data + * Message object + * @returns CustomEvent + */ + createEvent: function(command, data) { + return new this._window.CustomEvent('WebChannelMessageToChrome', { + detail: { + id: this.id, + message: { + command: command, + data: data + } + } + }); + } + }); + + return WebChannel; +}); diff --git a/app/scripts/lib/session.js b/app/scripts/lib/session.js index ad24aa2f57..911db663ad 100644 --- a/app/scripts/lib/session.js +++ b/app/scripts/lib/session.js @@ -146,7 +146,8 @@ define([ // a circular dependency that causes the FxaClientWrapper to not // load. isOAuth: function () { - return !! this.get('client_id'); + // It is OAuth if client_id is set, or service is present (service that is not sync) + return !! ( this.get('client_id') || (this.get('service') && this.service !== 'sync')); }, // BEGIN TEST API diff --git a/app/scripts/views/mixins/service-mixin.js b/app/scripts/views/mixins/service-mixin.js index 1436073309..a3cd0e632d 100644 --- a/app/scripts/views/mixins/service-mixin.js +++ b/app/scripts/views/mixins/service-mixin.js @@ -30,7 +30,7 @@ define([ var SYNC_SERVICE = 'sync'; - var EXPECT_CHANNEL_RESPONSE_TIMEOUT = 10000; + var EXPECT_CHANNEL_RESPONSE_TIMEOUT = 5000; function shouldSetupOAuthLinksOnError () { /*jshint validthis: true*/ @@ -41,27 +41,62 @@ define([ function notifyChannel(message, data) { /*jshint validthis: true*/ var self = this; - // Assume the receiver of the channel's notification will either // respond or shut the FxA window. // If it doesn't, assume there was an error and show a generic // error to the user - self._expectResponseTimeout = self.setTimeout(function() { - self.displayError(OAuthErrors.toError('TRY_AGAIN'), OAuthErrors); - }, EXPECT_CHANNEL_RESPONSE_TIMEOUT); + if (data && data.timeout) { + self._expectResponseTimeout = self.setTimeout(function () { + self.displayError(OAuthErrors.toError('TRY_AGAIN'), OAuthErrors); + }, EXPECT_CHANNEL_RESPONSE_TIMEOUT); + } return Channels.sendExpectResponse(message, data, { window: self.window, channel: self.channel - }).then(function (response) { - self.clearTimeout(self._expectResponseTimeout); - return response; - }, function (err) { + }).then(null, function (err) { self.clearTimeout(self._expectResponseTimeout); throw err; }); } + /** + * Apply additional result data depending on the current channel environment + * + * @param {Object} result + * @param {Object} options + * @returns {Object} + */ + function decorateOAuthResult(result, options) { + options = options || {}; + + // if specific to the WebChannel flow + if (Channels.getWebChannelId(options.context)) { + // set closeWindow + result.closeWindow = options.viewOptions && options.viewOptions.source === 'signin'; + // if the source is "signin" then set a timeout for a successful WebChannel signin + if (options.viewOptions.source === 'signin') { + result.timeout = 3000; + } + } + + return p(result); + } + + /** + * Formats the OAuth "result.redirect" url into a {code, state} object + * + * @param {Object} result + * @returns {Object} + */ + function formatOAuthResult(result) { + // get code and state from redirect params + var redirectParams = result.redirect.split('?')[1]; + result.state = Url.searchParam('state', redirectParams); + result.code = Url.searchParam('code', redirectParams); + return p(result); + } + return { setupOAuth: function (params) { if (! this._configLoader) { @@ -74,7 +109,7 @@ define([ // params listed in: // https://github.com/mozilla/fxa-oauth-server/blob/master/docs/api.md#post-v1authorization params = Url.searchParams(this.window.location.search, - ['client_id', 'redirect_uri', 'state', 'scope', 'action']); + ['client_id', 'redirect_uri', 'state', 'scope', 'action', 'webChannelId']); } this._oAuthParams = params; @@ -115,8 +150,7 @@ define([ }); }, - finishOAuthFlow: buttonProgressIndicator(function (options) { - options = options || {}; + finishOAuthFlow: buttonProgressIndicator(function (viewOptions) { var self = this; return this._configLoader.fetch().then(function(config) { @@ -127,9 +161,21 @@ define([ return self._oAuthClient.getCode(self._oAuthParams); }) .then(function(result) { - result.source = options.source; + + return formatOAuthResult(result); + }) + .then(function(result) { + + return decorateOAuthResult(result, { + context: self.window, + viewOptions: viewOptions + }); + }) + .then(function(result) { + return notifyChannel.call(self, 'oauth_complete', result); - }).then(function() { + }) + .then(function() { Session.clear('oauth'); // on success, keep the button progress indicator going until the // window closes. diff --git a/app/scripts/views/ready.js b/app/scripts/views/ready.js index 3bdc1e20ed..678a478f80 100644 --- a/app/scripts/views/ready.js +++ b/app/scripts/views/ready.js @@ -51,9 +51,14 @@ function (_, BaseView, FormView, Template, Session, Xss, Strings, AuthErrors, Se var serviceName = this.serviceName; if (this.serviceRedirectURI) { - serviceName = Strings.interpolate('%s', [ - Xss.href(this.serviceRedirectURI), serviceName - ]); + // if this is a WebChannel flow, then do not show any links, just finish the flow automatically + if (Session.oauth && Session.oauth.webChannelId) { + serviceName = Strings.interpolate('%s', [serviceName]); + } else { + serviceName = Strings.interpolate('%s', [ + Xss.href(this.serviceRedirectURI), serviceName + ]); + } } return { @@ -71,6 +76,10 @@ function (_, BaseView, FormView, Template, Session, Xss, Strings, AuthErrors, Se afterRender: function() { var graphic = this.$el.find('.graphic'); graphic.addClass('pulse'); + // Finish the WebChannel flow + if (Session.oauth && Session.oauth.webChannelId) { + this.submit(); + } return this._createMarketingSnippet(); }, diff --git a/app/tests/mocks/window.js b/app/tests/mocks/window.js index e12c49447e..1496545f9d 100644 --- a/app/tests/mocks/window.js +++ b/app/tests/mocks/window.js @@ -55,7 +55,11 @@ function (_, Backbone) { this.dispatchedEvents = {}; } - this.dispatchedEvents[msg] = true; + if (typeof msg === 'object') { + this.dispatchedEvents[msg.command] = msg; + } else { + this.dispatchedEvents[msg] = true; + } }, isEventDispatched: function (eventName) { diff --git a/app/tests/spec/lib/channels/web.js b/app/tests/spec/lib/channels/web.js new file mode 100644 index 0000000000..2fc9eef412 --- /dev/null +++ b/app/tests/spec/lib/channels/web.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + + +define([ + 'underscore', + 'chai', + 'router', + 'views/sign_in', + 'lib/channels/web', + '/tests/mocks/window.js' + ], + function (_, chai, Router, View, WebChannel, WindowMock) { + var assert = chai.assert; + + describe('lib/channel/web', function () { + it('requires an id', function (done) { + try { + new WebChannel(); + } catch (e) { + assert.equal(e.message, 'WebChannel must have an id'); + done(); + } + }); + + describe('send', function () { + var windowMock; + var channel; + + beforeEach(function () { + windowMock = new WindowMock(); + }); + + it('sends an event with a callback', function (done) { + channel = new WebChannel('MyChannel', windowMock); + channel.init({ + window: windowMock + }); + + channel.send('after_render', {}, function (err, response) { + assert.notOk(err); + assert.ok(windowMock.dispatchedEvents['after_render']); + done(); + }); + }); + + it('throws an error if dispatchEvent fails', function (done) { + windowMock.dispatchEvent = function () { + throw new Error('Not supported'); + }; + + channel = new WebChannel('MyChannel', windowMock); + channel.init({ + window: windowMock + }); + + channel.send('after_render', {}, function (err, response) { + assert.equal(err.message, 'Not supported'); + assert.notOk(response); + done(); + } + ); + }); + }); + }); + }); diff --git a/app/tests/test_start.js b/app/tests/test_start.js index a426027f49..f6f5f81ead 100644 --- a/app/tests/test_start.js +++ b/app/tests/test_start.js @@ -14,6 +14,7 @@ function (Translator, Session, FxaClientWrapper) { '../tests/spec/lib/channels/null', '../tests/spec/lib/channels/fx-desktop', '../tests/spec/lib/channels/redirect', + '../tests/spec/lib/channels/web', '../tests/spec/lib/xss', '../tests/spec/lib/url', '../tests/spec/lib/session',