diff options
Diffstat (limited to 'services/fxaccounts/FxAccountsOAuthClient.jsm')
-rw-r--r-- | services/fxaccounts/FxAccountsOAuthClient.jsm | 269 |
1 files changed, 269 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsOAuthClient.jsm b/services/fxaccounts/FxAccountsOAuthClient.jsm new file mode 100644 index 000000000..c59f1a869 --- /dev/null +++ b/services/fxaccounts/FxAccountsOAuthClient.jsm @@ -0,0 +1,269 @@ +/* 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/. */ + +/** + * Firefox Accounts OAuth browser login helper. + * Uses the WebChannel component to receive OAuth messages and complete login flows. + */ + +this.EXPORTED_SYMBOLS = ["FxAccountsOAuthClient"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +XPCOMUtils.defineLazyModuleGetter(this, "WebChannel", + "resource://gre/modules/WebChannel.jsm"); +Cu.importGlobalProperties(["URL"]); + +/** + * Create a new FxAccountsOAuthClient for browser some service. + * + * @param {Object} options Options + * @param {Object} options.parameters + * Opaque alphanumeric token to be included in verification links + * @param {String} options.parameters.client_id + * OAuth id returned from client registration + * @param {String} options.parameters.state + * A value that will be returned to the client as-is upon redirection + * @param {String} options.parameters.oauth_uri + * The FxA OAuth server uri + * @param {String} options.parameters.content_uri + * The FxA Content server uri + * @param {String} [options.parameters.scope] + * Optional. A colon-separated list of scopes that the user has authorized + * @param {String} [options.parameters.action] + * Optional. If provided, should be either signup, signin or force_auth. + * @param {String} [options.parameters.email] + * Optional. Required if options.paramters.action is 'force_auth'. + * @param {Boolean} [options.parameters.keys] + * Optional. If true then relier-specific encryption keys will be + * available in the second argument to onComplete. + * @param [authorizationEndpoint] {String} + * Optional authorization endpoint for the OAuth server + * @constructor + */ +this.FxAccountsOAuthClient = function(options) { + this._validateOptions(options); + this.parameters = options.parameters; + this._configureChannel(); + + let authorizationEndpoint = options.authorizationEndpoint || "/authorization"; + + try { + this._fxaOAuthStartUrl = new URL(this.parameters.oauth_uri + authorizationEndpoint + "?"); + } catch (e) { + throw new Error("Invalid OAuth Url"); + } + + let params = this._fxaOAuthStartUrl.searchParams; + params.append("client_id", this.parameters.client_id); + params.append("state", this.parameters.state); + params.append("scope", this.parameters.scope || ""); + params.append("action", this.parameters.action || "signin"); + params.append("webChannelId", this._webChannelId); + if (this.parameters.keys) { + params.append("keys", "true"); + } + // Only append if we actually have a value. + if (this.parameters.email) { + params.append("email", this.parameters.email); + } +}; + +this.FxAccountsOAuthClient.prototype = { + /** + * Function that gets called once the OAuth flow is complete. + * The callback will receive an object with code and state properties. + * If the keys parameter was specified and true, the callback will receive + * a second argument with kAr and kBr properties. + */ + onComplete: null, + /** + * Function that gets called if there is an error during the OAuth flow, + * for example due to a state mismatch. + * The callback will receive an Error object as its argument. + */ + onError: null, + /** + * Configuration object that stores all OAuth parameters. + */ + parameters: null, + /** + * WebChannel that is used to communicate with content page. + */ + _channel: null, + /** + * Boolean to indicate if this client has completed an OAuth flow. + */ + _complete: false, + /** + * The url that opens the Firefox Accounts OAuth flow. + */ + _fxaOAuthStartUrl: null, + /** + * WebChannel id. + */ + _webChannelId: null, + /** + * WebChannel origin, used to validate origin of messages. + */ + _webChannelOrigin: null, + /** + * Opens a tab at "this._fxaOAuthStartUrl". + * Registers a WebChannel listener and sets up a callback if needed. + */ + launchWebFlow: function () { + if (!this._channelCallback) { + this._registerChannel(); + } + + if (this._complete) { + throw new Error("This client already completed the OAuth flow"); + } else { + let opener = Services.wm.getMostRecentWindow("navigator:browser").gBrowser; + opener.selectedTab = opener.addTab(this._fxaOAuthStartUrl.href); + } + }, + + /** + * Release all resources that are in use. + */ + tearDown: function() { + this.onComplete = null; + this.onError = null; + this._complete = true; + this._channel.stopListening(); + this._channel = null; + }, + + /** + * Configures WebChannel id and origin + * + * @private + */ + _configureChannel: function() { + this._webChannelId = "oauth_" + this.parameters.client_id; + + // if this.parameters.content_uri is present but not a valid URI, then this will throw an error. + try { + this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri, null, null); + } catch (e) { + throw e; + } + }, + + /** + * Create a new channel with the WebChannelBroker, setup a callback listener + * @private + */ + _registerChannel: function() { + /** + * Processes messages that are called back from the FxAccountsChannel + * + * @param webChannelId {String} + * Command webChannelId + * @param message {Object} + * Command message + * @param sendingContext {Object} + * Channel message event sendingContext + * @private + */ + let listener = function (webChannelId, message, sendingContext) { + if (message) { + let command = message.command; + let data = message.data; + let target = sendingContext && sendingContext.browser; + + switch (command) { + case "oauth_complete": + // validate the returned state and call onComplete or onError + let result = null; + let err = null; + + if (this.parameters.state !== data.state) { + err = new Error("OAuth flow failed. State doesn't match"); + } else if (this.parameters.keys && !data.keys) { + err = new Error("OAuth flow failed. Keys were not returned"); + } else { + result = { + code: data.code, + state: data.state + }; + } + + // if the message asked to close the tab + if (data.closeWindow && target) { + // for e10s reasons the best way is to use the TabBrowser to close the tab. + let tabbrowser = target.getTabBrowser(); + + if (tabbrowser) { + let tab = tabbrowser.getTabForBrowser(target); + + if (tab) { + tabbrowser.removeTab(tab); + log.debug("OAuth flow closed the tab."); + } else { + log.debug("OAuth flow failed to close the tab. Tab not found in TabBrowser."); + } + } else { + log.debug("OAuth flow failed to close the tab. TabBrowser not found."); + } + } + + if (err) { + log.debug(err.message); + if (this.onError) { + this.onError(err); + } + } else { + log.debug("OAuth flow completed."); + if (this.onComplete) { + if (this.parameters.keys) { + this.onComplete(result, data.keys); + } else { + this.onComplete(result); + } + } + } + + // onComplete will be called for this client only once + // calling onComplete again will result in a failure of the OAuth flow + this.tearDown(); + break; + } + } + }; + + this._channelCallback = listener.bind(this); + this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin); + this._channel.listen(this._channelCallback); + log.debug("Channel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath); + }, + + /** + * Validates the required FxA OAuth parameters + * + * @param options {Object} + * OAuth client options + * @private + */ + _validateOptions: function (options) { + if (!options || !options.parameters) { + throw new Error("Missing 'parameters' configuration option"); + } + + ["oauth_uri", "client_id", "content_uri", "state"].forEach(option => { + if (!options.parameters[option]) { + throw new Error("Missing 'parameters." + option + "' parameter"); + } + }); + + if (options.parameters.action == "force_auth" && !options.parameters.email) { + throw new Error("parameters.email is required for action 'force_auth'"); + } + }, +}; |