Skip to content
This repository has been archived by the owner on Apr 3, 2019. It is now read-only.

Commit

Permalink
feat(oauth): Add WebChannel support
Browse files Browse the repository at this point in the history
  • Loading branch information
vladikoff committed Aug 7, 2014
1 parent 245f269 commit 97b714b
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 22 deletions.
32 changes: 29 additions & 3 deletions app/scripts/lib/channels.js
Expand Up @@ -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 {
Expand All @@ -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();
Expand All @@ -41,7 +49,7 @@ define([
}

channel.init({
window: options.window || window
window: context
});

return channel;
Expand Down Expand Up @@ -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;
}
};
});
Expand Down
83 changes: 83 additions & 0 deletions 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;
});
3 changes: 2 additions & 1 deletion app/scripts/lib/session.js
Expand Up @@ -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
Expand Down
74 changes: 60 additions & 14 deletions app/scripts/views/mixins/service-mixin.js
Expand Up @@ -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*/
Expand All @@ -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) {
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
15 changes: 12 additions & 3 deletions app/scripts/views/ready.js
Expand Up @@ -51,9 +51,14 @@ function (_, BaseView, FormView, Template, Session, Xss, Strings, AuthErrors, Se
var serviceName = this.serviceName;

if (this.serviceRedirectURI) {
serviceName = Strings.interpolate('<a href="%s" class="no-underline" id="redirectTo">%s</a>', [
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('<a href="%s" class="no-underline" id="redirectTo">%s</a>', [
Xss.href(this.serviceRedirectURI), serviceName
]);
}
}

return {
Expand All @@ -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();
},
Expand Down
6 changes: 5 additions & 1 deletion app/tests/mocks/window.js
Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions 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();
}
);
});
});
});
});

0 comments on commit 97b714b

Please sign in to comment.