packages/fxa-content-server/app/scripts/lib/app-start.js (646 lines of code) (raw):
/* 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/. */
// this module starts it all.
/**
* the flow:
* 1) Initialize session information from URL search parameters.
* 2) Fetch /config from the backend, the returned info includes a flag that
* indicates whether cookies are enabled.
* 3) Fetch translations from the backend.
* 4) Create the web/desktop communication channel.
* 5) If cookies are disabled, go to the /cookies_disabled page.
* 6) Start the app if cookies are enabled.
*/
import _ from 'underscore';
import ExperimentGroupingRules from './experiments/grouping-rules/index';
import AppView from '../views/app';
import authBrokers from '../models/auth_brokers/index';
import AuthorityRelier from '../models/reliers/pairing/authority';
import Backbone from 'backbone';
import Cocktail from './cocktail';
import Constants from './constants';
import Environment from './environment';
import ErrorUtils from './error-utils';
import FormPrefill from '../models/form-prefill';
import FxaClient from './fxa-client';
import InterTabChannel from './channels/inter-tab';
import Metrics from './metrics';
import Notifier from './channels/notifier';
import Nimbus from './nimbus';
import OAuthClient from './oauth-client';
import OAuthRelier from '../models/reliers/oauth';
import p from './promise';
import ProfileClient from './profile-client';
import RefreshObserver from '../models/refresh-observer';
import Relier from '../models/reliers/relier';
import Router from './router';
import SameBrowserVerificationModel from '../models/verification/same-browser';
import ScreenInfo from './screen-info';
import SentryMetrics from './sentry';
import Session from './session';
import Storage from './storage';
import StorageMetrics from './storage-metrics';
import SupplicantRelier from '../models/reliers/pairing/supplicant';
import BrowserRelier from '../models/reliers/browser';
import Translator from './translator';
import UniqueUserId from '../models/unique-user-id';
import Url from './url';
import User from '../models/user';
import UserAgentMixin from './user-agent-mixin';
import WebChannel from './channels/web';
import GleanMetrics from './glean';
const AUTOMATED_BROWSER_STARTUP_DELAY = 750;
const DEVICE_PAIRING_SUPPLICANT_PATHNAME_REGEXP = /^\/pair\/supp/;
// note that we should handle /pair/ and /pair in the regex below
const DEVICE_PAIRING_AUTH_ENTRYPOINT_REGEXP = /^\/pair\/?$/;
function Start(options = {}) {
this._authenticationBroker = options.broker;
this._config = options.config || {};
this._history = options.history || Backbone.history;
this._metrics = options.metrics;
this._notifier = options.notifier;
this._refreshObserver = options.refreshObserver;
this._relier = options.relier;
this._router = options.router;
this._sentryMetrics = options.sentryMetrics;
this._storage = options.storage || Storage;
this._translator = options.translator;
this._user = options.user;
this._window = this.window = options.window || window;
}
Start.prototype = {
startApp() {
// The delay is to give the functional tests time to hook up
// WebChannel message response listeners.
const START_DELAY_MS = this._isAutomatedBrowser()
? AUTOMATED_BROWSER_STARTUP_DELAY
: 0;
return p
.delay(START_DELAY_MS)
.then(() => this.initializeDeps())
.then(() => this.testLocalStorage())
.then(() => this.allResourcesReady())
.catch((err) => this.fatalError(err));
},
initializeInterTabChannel() {
this._interTabChannel = new InterTabChannel();
},
initializeExperimentGroupingRules() {
this._experimentGroupingRules = new ExperimentGroupingRules({
env: this._config.env,
featureFlags: this._config.featureFlags,
rolloutRates: this._config.rolloutRates,
});
},
initializeDeps() {
return (
Promise.resolve()
// l10n depends on nothing, and depended upon
// by lots, it is loaded first.
.then(() => this.initializeL10n())
.then(() => this.initializeInterTabChannel())
.then(() => this.initializeExperimentGroupingRules())
.then(() => this.initializeErrorMetrics())
.then(() => this.initializeOAuthClient())
// both the metrics and router depend on the language
// fetched from config.
.then(() => this.initializeRelier())
// fxaClient depends on the relier and
// inter tab communication.
.then(() => this.initializeFxaClient())
// depends on nothing
.then(() => this.initializeNotificationChannel())
// depends on interTabChannel, web channel
.then(() => this.initializeNotifier())
// metrics depends on relier and notifier
.then(() => this.initializeMetrics())
// profileClient depends on fxaClient
.then(() => this.initializeProfileClient())
// broker relies on the relier, fxaClient,
// assertionLibrary, and metrics
.then(() => this.initializeAuthenticationBroker())
// user depends on the auth broker, profileClient, oAuthClient,
// and notifier.
.then(() => this.initializeUser())
// glean metrics
.then(() => this.initializeGlean())
// nimbus experimentation; does not rely on anything.
.then(() => this.initializeNimbusExperiment())
// depends on nothing
.then(() => this.initializeFormPrefill())
// depends on notifier, metrics
.then(() => this.initializeRefreshObserver())
// router depends on all of the above
.then(() => this.initializeRouter())
// appView depends on the router
.then(() => this.initializeAppView())
);
},
initializeErrorMetrics() {
if (this._config && this._config.env && this._experimentGroupingRules) {
const subject = {
env: this._config.env,
uniqueUserId: this._getUniqueUserId(),
};
if (this._experimentGroupingRules.choose('sentryEnabled', subject)) {
this.enableSentryMetrics();
}
}
},
enableSentryMetrics() {
this._sentryMetrics = new SentryMetrics(this._config);
},
initializeL10n() {
if (!this._translator) {
this._translator = new Translator();
}
return this._translator.fetch();
},
initializeGlean() {
return GleanMetrics.initialize(this._config.glean, {
metrics: this._metrics,
relier: this._relier,
user: this._user,
userAgent: this.getUserAgent(),
});
},
initializeNimbusExperiment() {
return Nimbus.initialize(this._getUniqueUserId(), {
'user-agent': this.getUserAgentString(),
});
},
initializeMetrics() {
const isSampledUser = this._experimentGroupingRules.choose(
'isSampledUser',
{
env: this._config.env,
uniqueUserId: this._getUniqueUserId(),
}
);
const relier = this._relier;
const screenInfo = new ScreenInfo(this._window);
this._metrics = this._createMetrics({
clientHeight: screenInfo.clientHeight,
clientWidth: screenInfo.clientWidth,
context: relier.get('context'),
devicePixelRatio: screenInfo.devicePixelRatio,
entrypoint: relier.get('entrypoint'),
entrypointExperiment: relier.get('entrypointExperiment'),
entrypointVariation: relier.get('entrypointVariation'),
isSampledUser: isSampledUser,
lang: this._config.lang,
maxEventOffset: this._config.maxEventOffset,
notifier: this._notifier,
screenHeight: screenInfo.screenHeight,
screenWidth: screenInfo.screenWidth,
sentryMetrics: this._sentryMetrics,
service: relier.get('service'),
uniqueUserId: this._getUniqueUserId(),
utmCampaign: relier.get('utmCampaign'),
utmContent: relier.get('utmContent'),
utmMedium: relier.get('utmMedium'),
utmSource: relier.get('utmSource'),
utmTerm: relier.get('utmTerm'),
});
},
initializeFormPrefill() {
this._formPrefill = new FormPrefill();
},
initializeOAuthClient() {
this._oAuthClient = new OAuthClient({
oAuthUrl: this._config.oAuthUrl,
});
},
initializeProfileClient() {
this._profileClient = new ProfileClient({
profileUrl: this._config.profileUrl,
});
},
initializeRelier() {
if (!this._relier) {
let relier;
const context = this._getContext();
// The order of the checks is important. The OAuth check
// is more strict than the Sync check, and if the Sync
// check is done first, a bad acting OAuth relier could
// specify both a client_id and service=sync which would
// cause us to present the Sync UI to the user.
// Unfortunately, the Sync check cannot be made as strict
// as the OAuth check - when users sign up for Sync
// and verify in a 2nd browser, all we have to know the user
// is completing a Sync flow is `service=sync`.
if (this.isDevicePairingAsAuthority()) {
relier = new AuthorityRelier(
{},
{
config: this._config,
oAuthClient: this._oAuthClient,
oAuthClientId: this._config.oAuthClientId,
oAuthUrl: this._config.oAuthUrl,
}
);
} else if (this.isDevicePairingAsSupplicant()) {
relier = new SupplicantRelier(
{},
{
config: this._config,
isSupplicant: true,
oAuthClient: this._oAuthClient,
oAuthClientId: this._config.oAuthClientId,
oAuthUrl: this._config.oAuthUrl,
}
);
} else if (this._isOAuth()) {
relier = new OAuthRelier(
{ context },
{
config: this._config,
isVerification: this._isVerification(),
oAuthClient: this._oAuthClient,
sentryMetrics: this._sentryMetrics,
session: Session,
window: this._window,
}
);
} else if (
this._isServiceSync() ||
// context v3 is able to sign in to Firefox without enabling Sync
this._searchParam('context') === Constants.FX_DESKTOP_V3_CONTEXT
) {
relier = new BrowserRelier(
{ context },
{
config: this._config,
isVerification: this._isVerification(),
sentryMetrics: this._sentryMetrics,
translator: this._translator,
window: this._window,
}
);
} else {
relier = new Relier(
{ context },
{
config: this._config,
isVerification: this._isVerification(),
sentryMetrics: this._sentryMetrics,
window: this._window,
}
);
}
this._relier = relier;
return relier.fetch();
}
},
initializeAuthenticationBroker() {
if (!this._authenticationBroker) {
let context;
if (this._isOAuth()) {
context = this._chooseOAuthBrokerContext();
} else {
context = this._getContext();
}
const Constructor = authBrokers.get(context);
this._authenticationBroker = new Constructor({
config: this._config,
fxaClient: this._fxaClient,
isVerificationSameBrowser: this._isVerificationSameBrowser(),
metrics: this._metrics,
notificationChannel: this._notificationChannel,
notifier: this._notifier,
oAuthClient: this._oAuthClient,
relier: this._relier,
session: Session,
window: this._window,
});
this._authenticationBroker.on('error', this.captureError.bind(this));
this._metrics.setBrokerType(this._authenticationBroker.type);
return this._authenticationBroker.fetch();
}
},
/**
* Chooses the right OAuth broker context
* @returns {string}
* @private
*/
_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 {
return Constants.OAUTH_CONTEXT;
}
},
initializeFxaClient() {
if (!this._fxaClient) {
this._fxaClient = new FxaClient({
authServerUrl: this._config.authServerUrl,
interTabChannel: this._interTabChannel,
});
}
},
initializeUser() {
if (!this._user) {
this._user = new User({
fxaClient: this._fxaClient,
metrics: this._metrics,
notifier: this._notifier,
oAuthClient: this._oAuthClient,
oAuthClientId: this._config.oAuthClientId,
profileClient: this._profileClient,
sentryMetrics: this._sentryMetrics,
subscriptionsConfig: this._config.subscriptions,
storage: this._getUserStorageInstance(),
uniqueUserId: this._getUniqueUserId(),
});
// The storage formats must be upgraded before checking
// whether to set the signed in account from the browser
// or else an attempt can be made to populate an Account
// with data in the old format, causing an exception to
// be thrown.
return this._user
.removeAccountsWithInvalidUid()
.then(() => this._updateUserFromSigninCodeAccount())
.then(() => this._updateUserFromBrowserAccount());
}
},
_updateUserFromSigninCodeAccount() {
return Promise.resolve().then(() => {
const signinCodeAccount =
this._authenticationBroker.get('signinCodeAccount');
if (signinCodeAccount) {
return this._user.setSigninCodeAccount(signinCodeAccount);
}
});
},
_updateUserFromBrowserAccount() {
const user = this._user;
return Promise.resolve()
.then(() => {
const browserAccountData = this._authenticationBroker.get(
'browserSignedInAccount'
);
if (browserAccountData) {
return user.mergeBrowserAccount(browserAccountData);
}
})
.then((browserAccount) => {
const isPairing =
this.isDevicePairingAsAuthority() || this.isStartingPairing();
const shouldSetAsSignedInAccount =
user.shouldSetSignedInAccountFromBrowser(
this._relier.get('service'),
isPairing,
browserAccount
);
if (shouldSetAsSignedInAccount) {
return user.updateSignedInAccount(browserAccount);
}
});
},
initializeNotificationChannel() {
if (!this._notificationChannel) {
this._notificationChannel = new WebChannel(
Constants.ACCOUNT_UPDATES_WEBCHANNEL_ID
);
this._notificationChannel.initialize({
window: this._window,
isOAuthWebChannel: this.isOAuthWebChannel(),
});
}
},
initializeNotifier() {
if (!this._notifier) {
this._notifier = new Notifier({
tabChannel: this._interTabChannel,
webChannel: this._notificationChannel,
});
}
},
initializeRefreshObserver() {
if (!this._refreshObserver) {
this._refreshObserver = new RefreshObserver({
metrics: this._metrics,
notifier: this._notifier,
window: this._window,
});
}
},
_uniqueUserId: null,
_getUniqueUserId() {
if (!this._uniqueUserId) {
/**
* Sets a UUID value that is unrelated to any account information.
* This value is useful to determine if the logged out user qualifies
* for A/B testing or metrics.
*/
this._uniqueUserId = new UniqueUserId({
sentryMetrics: this._sentryMetrics,
window: this._window,
}).get('uniqueUserId');
}
return this._uniqueUserId;
},
createView(Constructor, options = {}) {
const viewOptions = _.extend(
{
broker: this._authenticationBroker,
config: this._config,
createView: this.createView.bind(this),
experimentGroupingRules: this._experimentGroupingRules,
formPrefill: this._formPrefill,
interTabChannel: this._interTabChannel,
isCoppaEnabled: this._config.isCoppaEnabled,
lang: this._config.lang,
metrics: this._metrics,
notifier: this._notifier,
relier: this._relier,
sentryMetrics: this._sentryMetrics,
session: Session,
subscriptionsManagementEnabled: this._config.subscriptions.enabled,
translator: this._translator,
user: this._user,
window: this._window,
},
this._router.getViewOptions(options)
);
return new Constructor(viewOptions);
},
initializeRouter() {
if (!this._router) {
this._router = new Router({
broker: this._authenticationBroker,
config: this._config,
createView: this.createView.bind(this),
experimentGroupingRules: this._experimentGroupingRules,
metrics: this._metrics,
notifier: this._notifier,
relier: this._relier,
user: this._user,
window: this._window,
});
}
this._window.router = this._router;
},
initializeAppView() {
if (!this._appView) {
this._appView = new AppView({
createView: this.createView.bind(this),
el: 'body',
environment: new Environment(this._window),
notifier: this._notifier,
router: this._router,
translator: this._translator,
window: this._window,
});
}
},
/**
* Check whether there are any problems accessing localStorage.
* Errors are logged to Sentry and internal metrics.
*
* If there is a problem accessing localStorage, the user
* will be redirected to `/cookies_disabled` from _selectStartPage
*
* @returns {Promise}
*/
testLocalStorage() {
return Promise.resolve()
.then(() => {
// only test localStorage if the user is not already at
// the cookies_disabled screen.
if (!this._isAtCookiesDisabled()) {
this._storage.testLocalStorage(this._window);
}
})
.catch((err) => this.captureError(err));
},
/**
* Handle a fatal error. Logs and reports the error, then redirects
* to the appropriate error page.
*
* @param {Error} error
* @returns {Promise}
*/
fatalError(error) {
if (!this._sentryMetrics) {
this.enableSentryMetrics();
}
return ErrorUtils.fatalError(
error,
this._sentryMetrics,
this._metrics,
this._window,
this._translator
);
},
/**
* Report an error to metrics. Send metrics report.
*
* @param {Object} error
* @return {Promise} resolves when complete
*/
captureError(error) {
if (!this._sentryMetrics) {
this.enableSentryMetrics();
}
return ErrorUtils.captureAndFlushError(
error,
this._sentryMetrics,
this._metrics,
this._window
);
},
allResourcesReady() {
// If a new start page is specified, do not attempt to render
// the route displayed in the URL because the user is
// immediately redirected
const startPage = this._selectStartPage();
const isSilent = !!startPage;
// pushState must be specified or else no screen transitions occur.
this._history.start({
pushState: this._canUseHistoryAPI(),
silent: isSilent,
});
if (startPage) {
this._router.navigate(
startPage,
{},
{
// do not add a history item for the page that was there BEFORE the selected start page.
replace: true,
trigger: true,
}
);
}
},
_canUseHistoryAPI() {
// Check whether the history API can be used by calling replaceState
// with the current window information. This fixes problems in some
// environments like the Firefox OS 1.x trusted UI where the history
// API is available, but can't be used.
const win = this._window;
try {
win.history.replaceState({}, win.document.title, win.location.href);
} catch (e) {
return false;
}
return true;
},
_getUserStorageInstance() {
return Storage.factory('localStorage', this._window);
},
_isServiceSync() {
return this._isService(Constants.SYNC_SERVICE);
},
/**
* Is the user initiating a device pairing flow as
* the auth device?
*
* @returns {Boolean}
*/
isDevicePairingAsAuthority() {
return (
this._searchParam('redirect_uri') ===
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?
* @returns {boolean}
*/
isStartingPairing() {
return DEVICE_PAIRING_AUTH_ENTRYPOINT_REGEXP.test(
this._window.location.pathname
);
},
/**
* Is the user initiating a device pairing flow as
* the supplicant device?
*
* @returns {Boolean}
*/
isDevicePairingAsSupplicant() {
return DEVICE_PAIRING_SUPPLICANT_PATHNAME_REGEXP.test(
this._window.location.pathname
);
},
_isServiceOAuth() {
const service = this._searchParam('service');
// any service that is not the sync service is automatically
// considered an OAuth service
return service && !this._isServiceSync();
},
_isService(compareToService) {
const service = this._searchParam('service');
return !!(service && compareToService && service === compareToService);
},
_isContext(contextName) {
return this._getContext() === contextName;
},
_getContext() {
if (this._isVerification()) {
return this._getVerificationContext();
}
return this._searchParam('context');
},
_getVerificationContext() {
// If the user verifies in the same browser, use the same context that
// was used to sign up to allow the verification tab to have the same
// capabilities as the signup tab.
// For users that verify in a 2nd browser, choose the most appropriate
// broker based on the service to allow the verification tab to have
// service specific behaviors and messaging. For Sync, use the generic
// Sync broker, for OAuth, use the OAuth broker.
// If no service is specified and the user is verifies in a 2nd browser,
// then fall back to the default content server context.
const sameBrowserVerificationContext =
this._getSameBrowserVerificationModel('context').get('context');
if (sameBrowserVerificationContext) {
// user is verifying in the same browser, use the same context they signed up with.
return sameBrowserVerificationContext;
} else if (this._isServiceSync()) {
// user is verifying in a different browser.
return Constants.FX_SYNC_CONTEXT;
} else if (this._isServiceOAuth()) {
// oauth, user is verifying in a different browser.
return Constants.OAUTH_CONTEXT;
}
return Constants.CONTENT_SERVER_CONTEXT;
},
_getSameBrowserVerificationModel(namespace) {
const urlVerificationInfo = Url.searchParams(this._window.location.search);
const verificationInfo = new SameBrowserVerificationModel(
{},
{
email: urlVerificationInfo.email,
namespace: namespace,
uid: urlVerificationInfo.uid,
}
);
verificationInfo.load();
return verificationInfo;
},
_isSignUpVerification() {
return this._searchParam('code') && this._searchParam('uid');
},
_isPasswordResetVerification() {
return this._searchParam('code') && this._searchParam('token');
},
_isReportSignIn() {
return this._window.location.pathname === '/report_signin';
},
_isVerification() {
return (
this._isSignUpVerification() ||
this._isPasswordResetVerification() ||
this._isReportSignIn()
);
},
/**
* Is the user verifying in the same browser they signed up/in to?
*
* @returns {Boolean}
* @private
*/
_isVerificationSameBrowser() {
return (
this._isVerification() &&
!!this._getSameBrowserVerificationModel('context').get('context')
);
},
_isOAuth() {
// signin/signup/force_auth
return (
!!(
this._searchParam('client_id') ||
// verification
this._isOAuthVerificationSameBrowser()
) ||
this._isOAuthVerificationDifferentBrowser() ||
// any URL with 'oauth' in the path.
/oauth/.test(this._window.location.pathname)
);
},
_isAtCookiesDisabled() {
return this._window.location.pathname === '/cookies_disabled';
},
_getSavedClientId() {
return Session.oauth && Session.oauth.client_id;
},
_isOAuthVerificationSameBrowser() {
return this._isVerification() && this._isService(this._getSavedClientId());
},
_isOAuthVerificationDifferentBrowser() {
return this._isVerification() && this._isServiceOAuth();
},
_searchParam(name) {
return Url.searchParam(name, this._window.location.search);
},
_selectStartPage() {
if (
!this._isAtCookiesDisabled() &&
!this._storage.isLocalStorageEnabled(this._window) &&
!this._isVerificationInMobileSafari()
) {
return 'cookies_disabled';
} else if (this.isDevicePairingAsAuthority()) {
return 'pair/auth/allow';
}
},
_createMetrics(options) {
if (this._isAutomatedBrowser()) {
return new StorageMetrics(options);
}
return new Metrics(options);
},
/**
* This function addresses the special scenario for Safari iOS
* where users using Private Browsing cannot verify their email
* (during sign up or sign-in confirmation) due to the
* 'window.localStorage' block.
*
* Returns `true` if the current browser is Safari iOS and the
* route is a verification email route.
*
* @returns {boolean}
* @private
*/
_isVerificationInMobileSafari() {
const path = this._window.location.pathname;
const isVerificationPath =
path === '/complete_signin' || path === '/verify_email';
const uap = this.getUserAgent();
return isVerificationPath && uap.isMobileSafari();
},
_isAutomatedBrowser() {
return this._searchParam('automatedBrowser') === 'true';
},
};
Cocktail.mixin(Start, UserAgentMixin);
export default Start;