diff --git a/packages/fxa-content-server/app/scripts/lib/app-start.js b/packages/fxa-content-server/app/scripts/lib/app-start.js index 1ade8541b80..c55cf308972 100644 --- a/packages/fxa-content-server/app/scripts/lib/app-start.js +++ b/packages/fxa-content-server/app/scripts/lib/app-start.js @@ -334,8 +334,12 @@ Start.prototype = { _chooseOAuthBrokerContext() { if (this.isDevicePairingAsAuthority()) { return Constants.DEVICE_PAIRING_AUTHORITY_CONTEXT; + } else if (this.isOAuthWebChannel() && this.isDevicePairingAsSupplicant()) { + return Constants.DEVICE_PAIRING_WEBCHANNEL_SUPPLICANT_CONTEXT; } else if (this.isDevicePairingAsSupplicant()) { return Constants.DEVICE_PAIRING_SUPPLICANT_CONTEXT; + } else if (this.isOAuthWebChannel()) { + return Constants.OAUTH_WEBCHANNEL_CONTEXT; } else if (this.getUserAgent().isChromeAndroid()) { return Constants.OAUTH_CHROME_ANDROID_CONTEXT; } else { @@ -646,6 +650,14 @@ Start.prototype = { Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI ); }, + /** + * Is the user initiating an OAuth flow using WebChannels? + * + * @returns {Boolean} + */ + isOAuthWebChannel() { + return this._searchParam('context') === Constants.OAUTH_WEBCHANNEL_CONTEXT; + }, /** * Is the user navigating to `/pair` or `/pair/` to start the pairing flow? diff --git a/packages/fxa-content-server/app/scripts/lib/channels/receivers/web-channel.js b/packages/fxa-content-server/app/scripts/lib/channels/receivers/web-channel.js index 2a62009d8de..a61f2a1b3dd 100644 --- a/packages/fxa-content-server/app/scripts/lib/channels/receivers/web-channel.js +++ b/packages/fxa-content-server/app/scripts/lib/channels/receivers/web-channel.js @@ -31,7 +31,21 @@ _.extend(WebChannelReceiver.prototype, Backbone.Events, { }, receiveMessage(event) { - const detail = event.detail; + let detail = event.detail; + + if (_.isString(detail)) { + // if the event arrives as a string, then we need to parse it + // this affects message channels such as the GeckoView WebExtension + try { + detail = JSON.parse(event.detail); + } catch (e) { + this._logger.error( + 'failed parsing WebChannelMessageToContent event', + detail + ); + return; + } + } if (!(detail && detail.id)) { // malformed message diff --git a/packages/fxa-content-server/app/scripts/lib/channels/web.js b/packages/fxa-content-server/app/scripts/lib/channels/web.js index 8c6386290fa..dce3aa7f861 100644 --- a/packages/fxa-content-server/app/scripts/lib/channels/web.js +++ b/packages/fxa-content-server/app/scripts/lib/channels/web.js @@ -30,6 +30,7 @@ const COMMANDS = { LOADED: 'fxaccounts:loaded', LOGIN: 'fxaccounts:login', LOGOUT: 'fxaccounts:logout', + OAUTH_LOGIN: 'fxaccounts:oauth_login', PAIR_AUTHORIZE: 'fxaccounts:pair_authorize', PAIR_COMPLETE: 'fxaccounts:pair_complete', PAIR_DECLINE: 'fxaccounts:pair_decline', diff --git a/packages/fxa-content-server/app/scripts/lib/constants.js b/packages/fxa-content-server/app/scripts/lib/constants.js index c12f2f39f93..7d5e637ef31 100644 --- a/packages/fxa-content-server/app/scripts/lib/constants.js +++ b/packages/fxa-content-server/app/scripts/lib/constants.js @@ -34,6 +34,7 @@ module.exports = { FX_FENNEC_V1_CONTEXT: 'fx_fennec_v1', FX_IOS_V1_CONTEXT: 'fx_ios_v1', OAUTH_CONTEXT: 'oauth', + OAUTH_WEBCHANNEL_CONTEXT: 'oauth_webchannel_v1', OAUTH_CHROME_ANDROID_CONTEXT: 'oauth_chrome_android', CONTENT_SERVER_SERVICE: 'content-server', @@ -68,6 +69,8 @@ module.exports = { 'profile:email', 'profile:uid', ], + OAUTH_WEBCHANNEL_REDIRECT: + 'urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel', RELIER_KEYS_LENGTH: 32, RELIER_KEYS_CONTEXT_INFO_PREFIX: 'identity.mozilla.com/picl/v1/oauth/', @@ -144,6 +147,8 @@ module.exports = { 'https://identity.mozilla.com/apps/oldsync', ], DEVICE_PAIRING_SUPPLICANT_CONTEXT: 'device_pairing_supplicant', + DEVICE_PAIRING_WEBCHANNEL_SUPPLICANT_CONTEXT: + 'device_pairing_webchannel_supplicant', TWO_STEP_AUTHENTICATION_ACR: 'AAL2', diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-channel.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-channel.js index d321b314848..710f41b05aa 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-channel.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-channel.js @@ -64,19 +64,6 @@ const FxSyncChannelAuthenticationBroker = FxSyncAuthenticationBroker.extend( sendChangePasswordNotice: true, }), - getCommand(commandName) { - if (!this.commands) { - throw new Error('this.commands must be specified'); - } - - const command = this.commands[commandName]; - if (!command) { - throw new Error('command not found for: ' + commandName); - } - - return command; - }, - /** * Initialize the broker * diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-web-channel.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-web-channel.js index 6474b3ed7de..c3ce0071fa2 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-web-channel.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/fx-sync-web-channel.js @@ -8,9 +8,11 @@ */ import _ from 'underscore'; +import Cocktail from 'cocktail'; import Constants from '../../lib/constants'; import FxSyncChannelAuthenticationBroker from './fx-sync-channel'; import WebChannel from '../../lib/channels/web'; +import ChannelMixin from './mixins/channel'; const proto = FxSyncChannelAuthenticationBroker.prototype; @@ -68,4 +70,6 @@ const FxSyncWebChannelAuthenticationBroker = FxSyncChannelAuthenticationBroker.e } ); +Cocktail.mixin(FxSyncWebChannelAuthenticationBroker, ChannelMixin); + export default FxSyncWebChannelAuthenticationBroker; diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/index.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/index.js index dffdd5733e5..9d110166a5b 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/index.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/index.js @@ -14,10 +14,12 @@ import FxDesktopV3broker from '../auth_brokers/fx-desktop-v3'; import FxFennecV1Broker from '../auth_brokers/fx-fennec-v1'; import FxIosV1Broker from '../auth_brokers/fx-ios-v1'; import OauthRedirectBroker from '../auth_brokers/oauth-redirect'; +import OauthWebChannelBroker from '../auth_brokers/oauth-webchannel-v1'; import OauthRedirectChromeAndroidBroker from '../auth_brokers/oauth-redirect-chrome-android'; import WebBroker from '../auth_brokers/web'; import AuthorityBroker from '../auth_brokers/pairing/authority'; import SupplicantBroker from '../auth_brokers/pairing/supplicant'; +import SupplicantWebChannelBroker from '../auth_brokers/pairing/supplicant-webchannel'; const AUTH_BROKERS = [ /* eslint-disable sorting/sort-object-props */ @@ -41,6 +43,10 @@ const AUTH_BROKERS = [ context: Constants.OAUTH_CONTEXT, Constructor: OauthRedirectBroker, }, + { + context: Constants.OAUTH_WEBCHANNEL_CONTEXT, + Constructor: OauthWebChannelBroker, + }, { context: Constants.OAUTH_CHROME_ANDROID_CONTEXT, Constructor: OauthRedirectChromeAndroidBroker, @@ -57,6 +63,10 @@ const AUTH_BROKERS = [ context: Constants.DEVICE_PAIRING_SUPPLICANT_CONTEXT, Constructor: SupplicantBroker, }, + { + context: Constants.DEVICE_PAIRING_WEBCHANNEL_SUPPLICANT_CONTEXT, + Constructor: SupplicantWebChannelBroker, + }, /* eslint-enable sorting/sort-object-props */ ].reduce((authBrokers, authBroker) => { authBrokers[authBroker.context] = authBroker.Constructor; diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/mixins/channel.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/mixins/channel.js index cea9397888e..a5dc2ef7175 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/mixins/channel.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/mixins/channel.js @@ -27,7 +27,40 @@ function ensureActionReturnsPromise(action) { return action; } -var ChannelMixin = { +const ChannelMixin = { + /** + * Get a reference to a channel. If a channel has already been created, + * the cached channel will be returned. Used by the ChannelMixin. + * + * @method getChannel + * @returns {Object} channel + */ + getChannel() { + if (!this._channel) { + this._channel = this.createChannel(); + } + + return this._channel; + }, + + /** + * Get a command by commandName + * @param commandName + * @returns {String} command + */ + getCommand(commandName) { + if (!this.commands) { + throw new Error('this.commands must be specified'); + } + + const command = this.commands[commandName]; + if (!command) { + throw new Error('command not found for: ' + commandName); + } + + return command; + }, + /** * Send a message to the remote listener, expect no response * @@ -37,8 +70,8 @@ var ChannelMixin = { * The promise will resolve if the value was successfully sent. */ send(message, data) { - var channel = this.getChannel(); - var send = ensureActionReturnsPromise(channel.send.bind(channel)); + const channel = this.getChannel(); + const send = ensureActionReturnsPromise(channel.send.bind(channel)); return send(message, data); }, @@ -53,10 +86,10 @@ var ChannelMixin = { * listener, or reject if there was an error. */ request(message, data) { - var channel = this.getChannel(); + const channel = this.getChannel(); // only new channels have a request. If not, fall back to send. - var action = (channel.request || channel.send).bind(channel); - var request = ensureActionReturnsPromise(action); + const action = (channel.request || channel.send).bind(channel); + const request = ensureActionReturnsPromise(action); return request(message, data); }, diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-redirect.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-redirect.js index e52fc187b40..9f62e75d72a 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-redirect.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-redirect.js @@ -88,7 +88,7 @@ export default BaseAuthenticationBroker.extend({ DELAY_BROKER_RESPONSE_MS: 100, sendOAuthResultToRelier(result) { - return this._metrics.flush().then(() => { + return Promise.resolve().then(() => { var extraParams = {}; if (result.error) { extraParams.error = result.error; @@ -345,7 +345,10 @@ export default BaseAuthenticationBroker.extend({ this.clearOriginalTabMarker(); return this.getOAuthResult(account).then(result => { result = _.extend(result, additionalResultData); - return this.sendOAuthResultToRelier(result); + + return this._metrics.flush().then(() => { + return this.sendOAuthResultToRelier(result, account); + }); }); }); }, diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-webchannel-v1.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-webchannel-v1.js new file mode 100644 index 00000000000..7bfb72e7bd2 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/oauth-webchannel-v1.js @@ -0,0 +1,91 @@ +/* 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/. */ + +/** + * WebChannel OAuth broker that speaks 'v1' of the protocol. + */ +import _ from 'underscore'; +import ChannelMixin from './mixins/channel'; +import Cocktail from 'cocktail'; +import Constants from '../../lib/constants'; +import HaltBehavior from '../../views/behaviors/halt'; +import OAuthRedirectAuthenticationBroker from './oauth-redirect'; +import ScopedKeys from 'lib/crypto/scoped-keys'; +import WebChannel from '../../lib/channels/web'; +import SyncEngines from '../sync-engines'; + +const proto = OAuthRedirectAuthenticationBroker.prototype; + +const OAuthWebChannelBroker = OAuthRedirectAuthenticationBroker.extend({ + defaultBehaviors: _.extend({}, proto.defaultBehaviors, { + afterForceAuth: new HaltBehavior(), + afterSignIn: new HaltBehavior(), + }), + + defaultCapabilities: _.extend({}, proto.defaultCapabilities, { + chooseWhatToSyncWebV1: true, + fxaStatus: true, + openWebmailButtonVisible: false, + }), + + commands: _.pick(WebChannel, 'FXA_STATUS', 'OAUTH_LOGIN'), + + type: 'oauth-webchannel-v1', + + initialize(options = {}) { + this.session = options.session; + this._channel = options.channel; + this._scopedKeys = ScopedKeys; + this._metrics = options.metrics; + + proto.initialize.call(this, options); + this.request(this.getCommand('FXA_STATUS'), { + service: this.relier.get('service'), + }).then(response => this.onFxaStatus(response)); + }, + + /** + * Handle a response to the `fxa_status` message. + * + * @param {any} [response={}] + * @private + */ + onFxaStatus(response = {}) { + const supportedEngines = + response.capabilities && response.capabilities.engines; + if (supportedEngines) { + // supportedEngines override the defaults + const syncEngines = new SyncEngines(null, { + engines: supportedEngines, + window: this.window, + }); + return this.set('chooseWhatToSyncWebV1Engines', syncEngines); + } + }, + + createChannel() { + const channel = new WebChannel(Constants.ACCOUNT_UPDATES_WEBCHANNEL_ID); + channel.initialize({ + window: this.window, + }); + + return channel; + }, + + DELAY_BROKER_RESPONSE_MS: 100, + + sendOAuthResultToRelier(result, account) { + result.redirect = Constants.OAUTH_WEBCHANNEL_REDIRECT; + if (account && account.get('declinedSyncEngines')) { + result.declinedSyncEngines = account.get('declinedSyncEngines'); + result.offeredSyncEngines = account.get('offeredSyncEngines'); + } + + return this.send(this.getCommand('OAUTH_LOGIN'), result); + }, +}); + +Cocktail.mixin(OAuthWebChannelBroker, ChannelMixin); + +export default OAuthWebChannelBroker; diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/mixins/supplicant.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/mixins/supplicant.js new file mode 100644 index 00000000000..48ff517a421 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/mixins/supplicant.js @@ -0,0 +1,63 @@ +/* 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/. */ + +import OAuthErrors from '../../../../lib/oauth-errors'; +import PairingChannelClient from '../../../../lib/pairing-channel-client'; +import SupplicantStateMachine from '../../../pairing/supplicant-state-machine'; +import setRemoteMetaData from '../remote-metadata'; + +/** + * Shared functions of the supplicant auth brokers + */ + +const SupplicantMixin = { + initialize(options) { + const { config, notifier, relier } = options; + + if (!config.pairingClients.includes(relier.get('clientId'))) { + // only approved clients may pair + throw OAuthErrors.toError('INVALID_PAIRING_CLIENT'); + } + + const channelServerUri = config.pairingChannelServerUri; + const { channelId, channelKey } = relier.toJSON(); + if (channelId && channelKey && channelServerUri) { + this.pairingChannelClient = new PairingChannelClient( + { + channelId, + channelKey, + channelServerUri, + }, + { + importPairingChannel: options.importPairingChannel, + notifier, + } + ); + + this.suppStateMachine = new SupplicantStateMachine( + {}, + { + broker: this, + notifier, + pairingChannelClient: this.pairingChannelClient, + relier, + } + ); + + this.pairingChannelClient.open(); + } else { + throw new Error('Failed to initialize supplicant'); + } + }, + + afterSupplicantApprove() { + return Promise.resolve().then(() => { + this.notifier.trigger('pair:supp:authorize'); + }); + }, + + setRemoteMetaData: setRemoteMetaData, +}; + +export default SupplicantMixin; diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant-webchannel.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant-webchannel.js new file mode 100644 index 00000000000..024248a39cf --- /dev/null +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant-webchannel.js @@ -0,0 +1,35 @@ +/* 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/. */ + +import Cocktail from 'cocktail'; +import OAuthWebChannelBroker from '../oauth-webchannel-v1'; +import SupplicantMixin from './mixins/supplicant'; + +/** + * SupplicantWebChannelBroker extends OAuthWebChannelBroker to provide a WebChannel flow + */ +class SupplicantWebChannelBroker extends OAuthWebChannelBroker { + type = 'supplicant-webchannel'; + + initialize(options = {}) { + super.initialize(options); + } + + sendCodeToRelier() { + return Promise.resolve().then(() => { + const relier = this.relier; + const result = { + redirect: relier.get('redirectUri'), + code: relier.get('code'), + state: relier.get('state'), + }; + + return this.sendOAuthResultToRelier(result); + }); + } +} + +Cocktail.mixin(SupplicantWebChannelBroker, SupplicantMixin); + +export default SupplicantWebChannelBroker; diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant.js index 5ce00c1d220..20f9d726051 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/pairing/supplicant.js @@ -2,61 +2,19 @@ * 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/. */ -import OAuthErrors from '../../../lib/oauth-errors'; +import Cocktail from 'cocktail'; import OAuthRedirectBroker from '../oauth-redirect'; -import PairingChannelClient from '../../../lib/pairing-channel-client'; -import setRemoteMetaData from './remote-metadata'; -import SupplicantStateMachine from '../../pairing/supplicant-state-machine'; +import SupplicantMixin from './mixins/supplicant'; import Url from '../../../lib/url'; -export default class SupplicantBroker extends OAuthRedirectBroker { +/** + * SupplicantBroker extends OAuthRedirectBroker to provide a redirect behaviour as an OAuth flow + */ +class SupplicantBroker extends OAuthRedirectBroker { type = 'supplicant'; initialize(options = {}) { super.initialize(options); - - const { config, notifier, relier } = options; - - if (!config.pairingClients.includes(relier.get('clientId'))) { - // only approved clients may pair - throw OAuthErrors.toError('INVALID_PAIRING_CLIENT'); - } - - const channelServerUri = config.pairingChannelServerUri; - const { channelId, channelKey } = relier.toJSON(); - if (channelId && channelKey && channelServerUri) { - this.pairingChannelClient = new PairingChannelClient( - { - channelId, - channelKey, - channelServerUri, - }, - { - importPairingChannel: options.importPairingChannel, - notifier, - } - ); - - this.suppStateMachine = new SupplicantStateMachine( - {}, - { - broker: this, - notifier, - pairingChannelClient: this.pairingChannelClient, - relier, - } - ); - - this.pairingChannelClient.open(); - } else { - throw new Error('Failed to initialize supplicant'); - } - } - - afterSupplicantApprove() { - return Promise.resolve().then(() => { - this.notifier.trigger('pair:supp:authorize'); - }); } sendCodeToRelier() { @@ -72,6 +30,8 @@ export default class SupplicantBroker extends OAuthRedirectBroker { this.sendOAuthResultToRelier(result); }); } - - setRemoteMetaData = setRemoteMetaData; } + +Cocktail.mixin(SupplicantBroker, SupplicantMixin); + +export default SupplicantBroker; diff --git a/packages/fxa-content-server/app/scripts/models/reliers/pairing/supplicant.js b/packages/fxa-content-server/app/scripts/models/reliers/pairing/supplicant.js index f37f49e8233..be4f2eeedc8 100644 --- a/packages/fxa-content-server/app/scripts/models/reliers/pairing/supplicant.js +++ b/packages/fxa-content-server/app/scripts/models/reliers/pairing/supplicant.js @@ -22,7 +22,9 @@ const SUPPLICANT_QUERY_PARAM_SCHEMA = { .required() .renameTo('keysJwk'), redirect_uri: Vat.url() - .required() + // redirect URI is not required for OAuth flows, + // we only validate it in the OAuth broker if it is provided + // See `isCorrectRedirect` app/scripts/models/reliers/oauth.js .renameTo('redirectUri'), scope: Vat.string() .required() diff --git a/packages/fxa-content-server/app/tests/spec/lib/channels/web.js b/packages/fxa-content-server/app/tests/spec/lib/channels/web.js index c637d4c1b26..240f1b70723 100644 --- a/packages/fxa-content-server/app/tests/spec/lib/channels/web.js +++ b/packages/fxa-content-server/app/tests/spec/lib/channels/web.js @@ -41,6 +41,7 @@ describe('lib/channels/web', () => { assert.ok(WebChannel.LOADED); assert.ok(WebChannel.LOGIN); assert.ok(WebChannel.LOGOUT); + assert.ok(WebChannel.OAUTH_LOGIN); assert.ok(WebChannel.PAIR_AUTHORIZE); assert.ok(WebChannel.PAIR_DECLINE); assert.ok(WebChannel.PAIR_REQUEST_SUPPLICANT_METADATA); @@ -54,7 +55,7 @@ describe('lib/channels/web', () => { window: windowMock, }); - assert.lengthOf(Object.keys(channel.COMMANDS), 16); + assert.lengthOf(Object.keys(channel.COMMANDS), 17); assert.ok(channel.COMMANDS.CAN_LINK_ACCOUNT); assert.ok(channel.COMMANDS.CHANGE_PASSWORD); assert.ok(channel.COMMANDS.DELETE); @@ -63,6 +64,7 @@ describe('lib/channels/web', () => { assert.ok(channel.COMMANDS.LOADED); assert.ok(channel.COMMANDS.LOGIN); assert.ok(channel.COMMANDS.LOGOUT); + assert.ok(channel.COMMANDS.OAUTH_LOGIN); assert.ok(channel.COMMANDS.PAIR_AUTHORIZE); assert.ok(channel.COMMANDS.PAIR_DECLINE); assert.ok(channel.COMMANDS.PAIR_COMPLETE); diff --git a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-redirect.js b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-redirect.js index 573fa3f9fe2..cc8bd55506e 100644 --- a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-redirect.js +++ b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-redirect.js @@ -94,6 +94,8 @@ describe('models/auth_brokers/oauth-redirect', () => { }); return broker.afterSignInConfirmationPoll(account).then(behavior => { + assert.isTrue(metrics.flush.calledOnce); + assert.lengthOf(metrics.flush.getCall(0).args, 0); assert.isTrue( broker.finishOAuthFlow.calledWith(account, { action: Constants.OAUTH_ACTION_SIGNIN, @@ -267,8 +269,6 @@ describe('models/auth_brokers/oauth-redirect', () => { redirect: REDIRECT_TO, }) .then(() => { - assert.isTrue(metrics.flush.calledOnce); - assert.lengthOf(metrics.flush.getCall(0).args, 0); assert.equal( windowMock.location.href, `${REDIRECT_TO}?state=state` @@ -285,8 +285,6 @@ describe('models/auth_brokers/oauth-redirect', () => { redirect: REDIRECT_TO, }) .then(() => { - assert.isTrue(metrics.flush.calledOnce); - assert.lengthOf(metrics.flush.getCall(0).args, 0); assert.include(windowMock.location.href, REDIRECT_TO); assert.include(windowMock.location.href, 'error=error'); assert.include(windowMock.location.href, 'state=state'); @@ -303,8 +301,6 @@ describe('models/auth_brokers/oauth-redirect', () => { redirect: REDIRECT_TO, }) .then(() => { - assert.isTrue(metrics.flush.calledOnce); - assert.lengthOf(metrics.flush.getCall(0).args, 0); assert.include(windowMock.location.href, REDIRECT_TO); assert.include(windowMock.location.href, 'action=' + action); assert.include(windowMock.location.href, 'state=state'); @@ -320,8 +316,6 @@ describe('models/auth_brokers/oauth-redirect', () => { redirect: REDIRECT_TO + '?test=param', }) .then(() => { - assert.isTrue(metrics.flush.calledOnce); - assert.lengthOf(metrics.flush.getCall(0).args, 0); assert.include(windowMock.location.href, REDIRECT_TO); assert.include(windowMock.location.href, 'test=param'); assert.include(windowMock.location.href, 'error=error'); diff --git a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-webchannel-v1.js b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-webchannel-v1.js new file mode 100644 index 00000000000..98d73ce7e4b --- /dev/null +++ b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/oauth-webchannel-v1.js @@ -0,0 +1,190 @@ +/* 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/. */ + +import { assert } from 'chai'; +import Constants from 'lib/constants'; +import NullChannel from 'lib/channels/null'; +import OAuthWebChannelBroker from 'models/auth_brokers/oauth-webchannel-v1'; +import Relier from 'models/reliers/base'; +import Session from 'lib/session'; +import sinon from 'sinon'; +import User from 'models/user'; +import WindowMock from '../../../mocks/window'; +import _ from 'underscore'; + +const HEX_CHARSET = '0123456789abcdef'; +function generateOAuthCode() { + let code = ''; + + for (let i = 0; i < 64; ++i) { + code += HEX_CHARSET.charAt(Math.floor(Math.random() * 16)); + } + + return code; +} + +const OAUTH_STATUS_MESSAGE = 'fxaccounts:fxa_status'; +const OAUTH_LOGIN_MESSAGE = 'fxaccounts:oauth_login'; +const REDIRECT_URI = 'https://127.0.0.1:8080'; +const VALID_OAUTH_CODE = generateOAuthCode(); + +describe('models/auth_brokers/oauth-webchannel-v1', () => { + let account; + let broker; + let channelMock; + let metrics; + let relier; + let user; + let windowMock; + + function createAuthBroker(options = {}) { + broker = new OAuthWebChannelBroker( + _.extend( + { + channel: channelMock, + metrics: metrics, + relier: relier, + session: Session, + window: windowMock, + }, + options + ) + ); + + broker.DELAY_BROKER_RESPONSE_MS = 0; + } + + beforeEach(() => { + metrics = { + flush: sinon.spy(() => Promise.resolve()), + logEvent: () => {}, + }; + relier = new Relier({ + action: 'action', + clientId: 'clientId', + redirectUri: REDIRECT_URI, + scope: 'scope', + service: 'service', + state: 'state', + }); + user = new User(); + + windowMock = new WindowMock(); + channelMock = new NullChannel(); + channelMock.send = sinon.spy(() => Promise.resolve()); + channelMock.request = sinon.spy(() => Promise.resolve()); + + account = user.initAccount({ + sessionToken: 'abc123', + }); + sinon.stub(account, 'createOAuthCode').callsFake(() => { + return Promise.resolve({ + code: VALID_OAUTH_CODE, + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + state: 'state', + }); + }); + + createAuthBroker(); + + sinon.spy(broker, 'finishOAuthFlow'); + }); + + it('status message', () => { + const statusMsg = channelMock.request.getCall(0).args; + assert.equal(statusMsg[0], OAUTH_STATUS_MESSAGE); + assert.deepEqual(statusMsg[1], { service: 'service' }); + }); + + it('passes code and state', () => { + return broker + .sendOAuthResultToRelier({ + code: 'code', + state: 'state', + }) + .then(() => { + const loginMsg = channelMock.send.getCall(0).args; + assert.equal(loginMsg[0], OAUTH_LOGIN_MESSAGE); + assert.deepEqual(loginMsg[1], { + code: 'code', + state: 'state', + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + }); + }); + }); + + it('handles declinedSyncEngines and offeredSyncEngines', () => { + account.set('declinedSyncEngines', ['history']); + account.set('offeredSyncEngines', ['history']); + + return broker + .sendOAuthResultToRelier( + { + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + }, + account + ) + .then(() => { + const loginMsg = channelMock.send.getCall(0).args; + assert.equal(loginMsg[0], OAUTH_LOGIN_MESSAGE); + assert.deepEqual(loginMsg[1], { + declinedSyncEngines: ['history'], + offeredSyncEngines: ['history'], + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + }); + }); + }); + + describe('login with an error', () => { + it('appends an error query parameter', () => { + return broker + .sendOAuthResultToRelier({ + error: 'error', + }) + .then(() => { + const loginMsg = channelMock.send.getCall(0).args; + assert.equal(loginMsg[0], OAUTH_LOGIN_MESSAGE); + assert.deepEqual(loginMsg[1], { + error: 'error', + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + }); + }); + }); + }); + + describe('login with an action', () => { + it('appends an action query parameter', () => { + var action = Constants.OAUTH_ACTION_SIGNIN; + return broker + .sendOAuthResultToRelier({ + action: action, + }) + .then(() => { + const loginMsg = channelMock.send.getCall(0).args; + assert.equal(loginMsg[0], OAUTH_LOGIN_MESSAGE); + assert.deepEqual(loginMsg[1], { + action: action, + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + }); + }); + }); + }); + + describe('login with existing query parameters', () => { + it('passes through existing parameters unchanged', () => { + return broker + .sendOAuthResultToRelier({ + error: 'error', + }) + .then(() => { + const loginMsg = channelMock.send.getCall(0).args; + assert.equal(loginMsg[0], OAUTH_LOGIN_MESSAGE); + assert.deepEqual(loginMsg[1], { + error: 'error', + redirect: Constants.OAUTH_WEBCHANNEL_REDIRECT, + }); + }); + }); + }); +}); diff --git a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/mixins/supplicant.js b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/mixins/supplicant.js new file mode 100644 index 00000000000..34f2ffc32fd --- /dev/null +++ b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/mixins/supplicant.js @@ -0,0 +1,73 @@ +/* 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/. */ + +import { assert } from 'chai'; +import SupplicantBroker from 'models/auth_brokers/pairing/supplicant'; +import Relier from 'models/reliers/relier'; +import { mockPairingChannel } from 'tests/mocks/pair'; +import Notifier from 'lib/channels/notifier'; +import sinon from 'sinon'; + +describe('models/auth_brokers/pairing/mixins/supplicant', function() { + let broker; + let config; + let relier; + let notifier; + + beforeEach(function() { + config = { + pairingChannelServerUri: 'ws://test', + pairingClients: ['3c49430b43dfba77'], + }; + relier = new Relier(); + relier.set({ + channelId: '1', + channelKey: 'dGVzdA==', + clientId: '3c49430b43dfba77', + redirectUri: 'https://example.com?code=1&state=2', + }); + notifier = new Notifier(); + + broker = new SupplicantBroker({ + config, + importPairingChannel: mockPairingChannel, + notifier, + relier, + }); + }); + + describe('initialize', () => { + it('creates a pairing channel and a state machine', () => { + assert.ok(broker.pairingChannelClient); + assert.ok(broker.suppStateMachine); + }); + + it('throws on bad initialization', () => { + relier.set({ + channelId: null, + channelKey: null, + }); + + assert.throws(() => { + broker = new SupplicantBroker({ + config, + notifier, + relier, + }); + }, 'Failed to initialize supplicant'); + }); + }); + + describe('afterSupplicantApprove', () => { + it('notifies', () => { + sinon.spy(broker.notifier, 'trigger'); + + return broker.afterSupplicantApprove().then(() => { + assert.isTrue( + broker.notifier.trigger.calledWith('pair:supp:authorize') + ); + }); + }); + }); +}); diff --git a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant-webchannel.js b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant-webchannel.js new file mode 100644 index 00000000000..643214bc28f --- /dev/null +++ b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant-webchannel.js @@ -0,0 +1,58 @@ +/* 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/. */ + +import { assert } from 'chai'; +import SupplicantWebChannelBroker from 'models/auth_brokers/pairing/supplicant-webchannel'; +import Relier from 'models/reliers/relier'; +import { mockPairingChannel } from 'tests/mocks/pair'; +import Notifier from 'lib/channels/notifier'; + +import sinon from 'sinon'; + +describe('models/auth_brokers/pairing/supplicant-webchannel', function() { + let broker; + let config; + let relier; + let notifier; + + beforeEach(function() { + config = { + pairingChannelServerUri: 'ws://test', + pairingClients: ['3c49430b43dfba77'], + }; + relier = new Relier(); + relier.set({ + channelId: '1', + channelKey: 'dGVzdA==', + clientId: '3c49430b43dfba77', + code: '1', + redirectUri: 'https://example.com?code=1&state=2', + state: '2', + }); + notifier = new Notifier(); + + broker = new SupplicantWebChannelBroker({ + config, + importPairingChannel: mockPairingChannel, + notifier, + relier, + }); + }); + + describe('sendCodeToRelier', () => { + it('sends result to relier', () => { + sinon.stub(broker, 'sendOAuthResultToRelier'); + + return broker.sendCodeToRelier().then(() => { + assert.isTrue( + broker.sendOAuthResultToRelier.calledWith({ + redirect: 'https://example.com?code=1&state=2', + code: '1', + state: '2', + }) + ); + }); + }); + }); +}); diff --git a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant.js b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant.js index 864abd0a622..31709bbb6f0 100644 --- a/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant.js +++ b/packages/fxa-content-server/app/tests/spec/models/auth_brokers/pairing/supplicant.js @@ -38,55 +38,6 @@ describe('models/auth_brokers/pairing/supplicant', function() { }); }); - describe('initialize', () => { - it('creates a pairing channel and a state machine', () => { - assert.ok(broker.pairingChannelClient); - assert.ok(broker.suppStateMachine); - }); - - it('throws on bad initialization', () => { - relier.set({ - channelId: null, - channelKey: null, - }); - - assert.throws(() => { - broker = new SupplicantBroker({ - config, - notifier, - relier, - }); - }, 'Failed to initialize supplicant'); - }); - - it('throws on bad clientId', () => { - relier.set({ - clientId: 'c6d74070a481bc10', - }); - - assert.throws(() => { - broker = new SupplicantBroker({ - config, - importPairingChannel: mockPairingChannel, - notifier, - relier, - }); - }, 'Invalid pairing client'); - }); - }); - - describe('afterSupplicantApprove', () => { - it('notifies', () => { - sinon.spy(broker.notifier, 'trigger'); - - return broker.afterSupplicantApprove().then(() => { - assert.isTrue( - broker.notifier.trigger.calledWith('pair:supp:authorize') - ); - }); - }); - }); - describe('sendCodeToRelier', () => { it('sends result to relier', () => { sinon.stub(broker, 'sendOAuthResultToRelier'); diff --git a/packages/fxa-content-server/app/tests/spec/models/pairing/supplicant-state-machine.js b/packages/fxa-content-server/app/tests/spec/models/pairing/supplicant-state-machine.js index 47541ddde68..fcf4dd2cddc 100644 --- a/packages/fxa-content-server/app/tests/spec/models/pairing/supplicant-state-machine.js +++ b/packages/fxa-content-server/app/tests/spec/models/pairing/supplicant-state-machine.js @@ -10,6 +10,7 @@ import sinon from 'sinon'; import { mockPairingChannel } from 'tests/mocks/pair'; import SupplicantBroker from 'models/auth_brokers/pairing/supplicant'; import SupplicantRelier from 'models/reliers/pairing/supplicant'; +import WindowMock from '../../../mocks/window'; import { SupplicantState, @@ -29,6 +30,7 @@ describe('models/auth_brokers/pairing/supplicant-state-machine', function() { let notifier; let broker; let mockChannelClient; + let windowMock; const config = { pairingChannelServerUri: 'ws://test', pairingClients: ['3c49430b43dfba77'], @@ -45,11 +47,13 @@ describe('models/auth_brokers/pairing/supplicant-state-machine', function() { }); notifier = new Notifier(); mockChannelClient = new PairingChannelClient(); + windowMock = new WindowMock(); broker = new SupplicantBroker({ config, importPairingChannel: mockPairingChannel, notifier, relier, + window: windowMock, }); }); diff --git a/packages/fxa-content-server/app/tests/test_start.js b/packages/fxa-content-server/app/tests/test_start.js index d7778a1240b..ffc9d0126da 100644 --- a/packages/fxa-content-server/app/tests/test_start.js +++ b/packages/fxa-content-server/app/tests/test_start.js @@ -90,10 +90,13 @@ require('./spec/models/auth_brokers/fx-sync-channel'); require('./spec/models/auth_brokers/fx-sync-web-channel'); require('./spec/models/auth_brokers/index'); require('./spec/models/auth_brokers/oauth-redirect'); +require('./spec/models/auth_brokers/oauth-webchannel-v1'); require('./spec/models/auth_brokers/oauth-redirect-chrome-android'); require('./spec/models/auth_brokers/pairing/authority'); require('./spec/models/auth_brokers/pairing/remote-metadata'); require('./spec/models/auth_brokers/pairing/supplicant'); +require('./spec/models/auth_brokers/pairing/supplicant-webchannel'); +require('./spec/models/auth_brokers/pairing/mixins/supplicant'); require('./spec/models/auth_brokers/web'); require('./spec/models/email'); require('./spec/models/email-resend'); diff --git a/packages/fxa-content-server/docs/relier-communication-protocols/fx-webchannel.md b/packages/fxa-content-server/docs/relier-communication-protocols/fx-webchannel.md index e09cae41373..b95f8117fa2 100644 --- a/packages/fxa-content-server/docs/relier-communication-protocols/fx-webchannel.md +++ b/packages/fxa-content-server/docs/relier-communication-protocols/fx-webchannel.md @@ -83,6 +83,27 @@ Sent when a user successfully authenticates with Firefox Accounts and sync can b See [Login Data](#loginData). +#### fxaccounts:oauth_login + +Sent when a user successfully authenticates via a WebChannel OAuth flow. + +##### data + +``` +{ + "code": "02f3cfea84ac4c143662b38d6c7f0c82c6f91eb041befc7cecda446b1b4887c1", + "state": "vHao1p6OizzwReCkQMSpZA", + "redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel", + "action": "signin", + "declinedSyncEngines": ["history"], + "offeredSyncEngines": ["history"] +} +``` + +##### support + +Supported by `oauth_webchannel_v1` context. + #### fxaccounts:verified The user has successfully verified their email address. @@ -212,11 +233,22 @@ An object containing browser supported capabilities. Only available with browser A list of optional supported engines. If we are unsure whether an optional engine should be displayed, it will only be displayed if the engine is in the list. -###### Possible values +###### Possible values (Firefox Desktop) - `addresses` - `creditcards` +###### Possible values (OAuth WebChannel Flow) + +- `addons` +- `addresses` +- `bookmarks` +- `creditcards` +- `history` +- `passwords` +- `preferences` +- `tabs` + #### declinedSyncEngines, offeredSyncEngines - `addons` diff --git a/packages/fxa-content-server/docs/relier-communication-protocols/oauth-webchannel.md b/packages/fxa-content-server/docs/relier-communication-protocols/oauth-webchannel.md new file mode 100644 index 00000000000..36672911874 --- /dev/null +++ b/packages/fxa-content-server/docs/relier-communication-protocols/oauth-webchannel.md @@ -0,0 +1,27 @@ +# Communication with OAuth WebChannels + +OAuth WebChannels is an extension of the [FxA WebChannel Desktop Flow](fx-webchannel.md). +It consists of similar messages as the Desktop flow. Some behaviours in the OAuth flow are different. + +This flow currently supports the following messages: + +- 'fxaccounts:fxa_status' +- 'fxaccounts:oauth_login' + +The `fxa_status` message in the OAuth flow supports specifying a custom list of Sync engines that the app supports. +See [fx-webchannel.md](fx-webchannel.md) for details of engine capabilities. + +## Communication with GeckoView applications + +To enable this feature in applications with GeckoView we ship a WebExtension +as part of the [firefox-accounts](https://github.com/mozilla-mobile/android-components/blob/master/components/service/firefox-accounts/README.md) Android component. + +``` + * Communication channel is established from web content to this class via webextension, as follows: + * [fxa-web-content] <--js events--> [fxawebchannel.js webextension] <--port messages--> [WebChannelFeature] + * + * [fxa-web-channel] [WebChannelFeature] Notes: + * fxa-status ------> | web content requests account status & device capabilities + * | <------ fxa-status-response this class responds, based on state of [accountManager] + * oauth-login ------> authentication completed within fxa web content, this class receives OAuth code & state +``` diff --git a/packages/fxa-content-server/tests/functional.js b/packages/fxa-content-server/tests/functional.js index d22a71fc360..bc49217e7e3 100644 --- a/packages/fxa-content-server/tests/functional.js +++ b/packages/fxa-content-server/tests/functional.js @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ module.exports = [ + 'tests/functional/oauth_webchannel.js', 'tests/functional/reset_password.js', 'tests/functional/oauth_require_totp.js', 'tests/functional/sign_up_with_code.js', diff --git a/packages/fxa-content-server/tests/functional/lib/selectors.js b/packages/fxa-content-server/tests/functional/lib/selectors.js index 71da6903d17..5914d7ae3b1 100644 --- a/packages/fxa-content-server/tests/functional/lib/selectors.js +++ b/packages/fxa-content-server/tests/functional/lib/selectors.js @@ -51,6 +51,7 @@ module.exports = { }, CHOOSE_WHAT_TO_SYNC: { ENGINE_ADDRESSES: '#sync-engine-addresses', + ENGINE_BOOKMARKS: '#sync-engine-bookmarks', ENGINE_CREDIT_CARDS: '#sync-engine-creditcards', ENGINE_HISTORY: '#sync-engine-history', ENGINE_PASSWORDS: '#sync-engine-passwords', diff --git a/packages/fxa-content-server/tests/functional/oauth_webchannel.js b/packages/fxa-content-server/tests/functional/oauth_webchannel.js new file mode 100644 index 00000000000..2bd209c36d9 --- /dev/null +++ b/packages/fxa-content-server/tests/functional/oauth_webchannel.js @@ -0,0 +1,126 @@ +/* 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'; + +const { registerSuite } = intern.getInterface('object'); +const TestHelpers = require('../lib/helpers'); +const FunctionalHelpers = require('./lib/helpers'); +const selectors = require('./lib/selectors'); + +const SERVICE_NAME = '123done'; +const PASSWORD = 'passwordzxcv'; + +let email; + +const { + click, + closeCurrentWindow, + createUser, + fillOutSignIn, + fillOutSignUp, + openFxaFromRp, + openVerificationLinkInNewTab, + switchToWindow, + testElementExists, + testElementTextInclude, + testIsBrowserNotified, + testUrlInclude, +} = FunctionalHelpers; + +registerSuite('oauth webchannel', { + beforeEach: function() { + email = TestHelpers.createEmail(); + + return this.remote.then( + FunctionalHelpers.clearBrowserState({ + '123done': true, + contentServer: true, + force: true, + }) + ); + }, + tests: { + signup: function() { + return ( + this.remote + .then( + openFxaFromRp('signup', { + query: { + context: 'oauth_webchannel_v1', + }, + webChannelResponses: { + 'fxaccounts:fxa_status': { + capabilities: { + engines: ['bookmarks', 'history'], + }, + signedInUser: null, + }, + }, + }) + ) + .then(testElementExists(selectors.SIGNUP.SUB_HEADER)) + .then(testUrlInclude('client_id=')) + .then(testUrlInclude('redirect_uri=')) + .then(testUrlInclude('state=')) + .then(testUrlInclude('context=')) + + .then(fillOutSignUp(email, PASSWORD)) + + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.HEADER)) + .then( + testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.ENGINE_BOOKMARKS) + ) + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.ENGINE_HISTORY)) + .then(click(selectors.CHOOSE_WHAT_TO_SYNC.SUBMIT)) + + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + .then(openVerificationLinkInNewTab(email, 0)) + + .then(switchToWindow(1)) + // wait for the verified window in the new tab + .then(testElementExists(selectors.SIGNUP_COMPLETE.HEADER)) + // user sees the name of the RP, but cannot redirect + .then( + testElementTextInclude( + selectors.SIGNUP_COMPLETE.SERVICE_NAME, + SERVICE_NAME + ) + ) + + // switch to the original window + .then(closeCurrentWindow()) + .then(testIsBrowserNotified('fxaccounts:oauth_login')) + ); + }, + signin: function() { + return this.remote + .then(openFxaFromRp('signin')) + .then(createUser(email, PASSWORD, { preVerified: true })) + .then( + openFxaFromRp('signin', { + query: { + context: 'oauth_webchannel_v1', + }, + webChannelResponses: { + 'fxaccounts:fxa_status': { + capabilities: { + engines: ['bookmarks', 'history'], + }, + signedInUser: null, + }, + }, + }) + ) + .then(testElementExists(selectors.SIGNIN.SUB_HEADER)) + .then(testUrlInclude('client_id=')) + .then(testUrlInclude('redirect_uri=')) + .then(testUrlInclude('state=')) + .then(testUrlInclude('context=')) + + .then(fillOutSignIn(email, PASSWORD)) + .then(testIsBrowserNotified('fxaccounts:oauth_login')); + }, + }, +});