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 10e13d4ad9..19991fe9ef 100644 --- a/packages/fxa-content-server/app/scripts/lib/app-start.js +++ b/packages/fxa-content-server/app/scripts/lib/app-start.js @@ -98,6 +98,7 @@ Start.prototype = { this._experimentGroupingRules = new ExperimentGroupingRules({ env: this._config.env, featureFlags: this._config.featureFlags, + rolloutRates: this._config.rolloutRates, }); }, diff --git a/packages/fxa-content-server/app/scripts/lib/experiment.js b/packages/fxa-content-server/app/scripts/lib/experiment.js index 6cf92dac0b..9348020526 100644 --- a/packages/fxa-content-server/app/scripts/lib/experiment.js +++ b/packages/fxa-content-server/app/scripts/lib/experiment.js @@ -15,6 +15,7 @@ const UA_OVERRIDE = 'FxATester'; */ const STARTUP_EXPERIMENTS = { generalizedReactApp: BaseExperiment, + keyStretchV2: BaseExperiment, }; /** diff --git a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js index eabfac5e63..518050c905 100644 --- a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js +++ b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js @@ -25,6 +25,18 @@ class ExperimentChoiceIndex { this._experimentGroupingRules = options.experimentGroupingRules || experimentGroupingRules; this._featureFlags = options.featureFlags; + + // Apply configured rollout rates + if (options.rolloutRates) { + for (const rule of this._experimentGroupingRules) { + if (typeof rule.setRolloutRate === 'function') { + const rate = options.rolloutRates[rule.name]; + if (typeof rate === 'number') { + rule.setRolloutRate(rate); + } + } + } + } } /** diff --git a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/key-stretch.js b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/key-stretch.js index aa8d609023..56405659b9 100644 --- a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/key-stretch.js +++ b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/key-stretch.js @@ -6,19 +6,20 @@ const BaseGroupingRule = require('./base'); -const GROUPS = [ - 'control', - 'v2', -]; +const GROUPS = ['control', 'v2']; -const ROLLOUT_RATE = 0.0; +const DEFAULT_ROLLOUT_RATE = 0.0; module.exports = class KeyStretchGroupingRule extends BaseGroupingRule { constructor() { super(); - this.name = 'key-stretch'; + this.name = 'keyStretchV2'; this.groups = GROUPS; - this.rolloutRate = ROLLOUT_RATE; + this.rolloutRate = DEFAULT_ROLLOUT_RATE; + } + + setRolloutRate(rate) { + this.rolloutRate = rate; } /** diff --git a/packages/fxa-content-server/app/scripts/lib/fxa-client.js b/packages/fxa-content-server/app/scripts/lib/fxa-client.js index 834a5fcfae..9bfbf6570b 100644 --- a/packages/fxa-content-server/app/scripts/lib/fxa-client.js +++ b/packages/fxa-content-server/app/scripts/lib/fxa-client.js @@ -139,7 +139,7 @@ function determineKeyStretchVersion() { if (params.get('stretch') === '2') { return 2; } - if (ExperimentMixin.isInExperimentGroup('key-stretch', 'v2')) { + if (ExperimentMixin.isInExperimentGroup('keyStretchV2', 'v2')) { return 2; } diff --git a/packages/fxa-content-server/app/scripts/lib/key-stretch-experiment-mixin.js b/packages/fxa-content-server/app/scripts/lib/key-stretch-experiment-mixin.js index 1ad5fac601..63fb089853 100644 --- a/packages/fxa-content-server/app/scripts/lib/key-stretch-experiment-mixin.js +++ b/packages/fxa-content-server/app/scripts/lib/key-stretch-experiment-mixin.js @@ -8,9 +8,7 @@ dependsOn: [ExperimentMixin], isInKeyStretchExperiment() { - const experimentGroup = this.getAndReportExperimentGroup( - 'key-stretch' - ); + const experimentGroup = this.getAndReportExperimentGroup('keyStretchV2'); return experimentGroup === 'v2'; }, }; diff --git a/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/key-stretch.js b/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/key-stretch.js index d562c79204..52afa20f00 100644 --- a/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/key-stretch.js +++ b/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/key-stretch.js @@ -3,9 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { assert } from 'chai'; - import Experiment from 'lib/experiments/grouping-rules/key-stretch-v2'; + import Experiment from 'lib/experiments/grouping-rules/key-stretch'; - describe('lib/experiments/grouping-rules/key-stretch-v2', () => { + describe('lib/experiments/grouping-rules/key-stretch', () => { let experiment; beforeEach(() => { @@ -36,13 +36,13 @@ }); it('returns true if rollout 100%', () => { - experiment.rolloutRate = 1.0; - assert.isTrue( - experiment.choose({ - experimentGroupingRules: { choose: () => experiment.name }, - uniqueUserId: 'user-id', - }) - ); - }); + experiment.rolloutRate = 1.0; + assert.isTrue( + experiment.choose({ + experimentGroupingRules: { choose: () => experiment.name }, + uniqueUserId: 'user-id', + }) + ); + }); }); }); diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index 8bc45e4b36..1a91b974fe 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -81,6 +81,12 @@ const settingsConfig = { signUpRoutes: config.get('showReactApp.signUpRoutes'), signInRoutes: config.get('showReactApp.signInRoutes'), }, + rolloutRates: { + keyStretchV2: config.get('rolloutRates.keyStretchV2'), + }, + featureFlags: { + keyStretchV2: config.get('featureFlags.keyStretchV2'), + }, }; // Inject Beta Settings meta content diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index cfa391caea..77f8abd361 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -220,6 +220,12 @@ const conf = (module.exports = convict({ format: Boolean, env: 'FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS', }, + keyStretchV2: { + default: true, + doc: 'Enables V2 key stretching', + format: Boolean, + env: 'FEATURE_FLAGS_KEY_STRETCH_V2', + }, }, showReactApp: { simpleRoutes: { @@ -789,6 +795,14 @@ const conf = (module.exports = convict({ env: 'WEBPACK_MODE_OVERRIDE', format: String, }, + rolloutRates: { + keyStretchV2: { + default: 0, + doc: 'The rollout rate for key stretching changes. Valid values are from 0 to 1.0', + env: 'ROLLOUT_KEY_STRETCH_V2', + format: Number, + }, + }, statsd: { enabled: { doc: 'Enable StatsD', diff --git a/packages/fxa-content-server/server/lib/routes/get-index.js b/packages/fxa-content-server/server/lib/routes/get-index.js index 1f11c7b6a0..71e4de64d3 100644 --- a/packages/fxa-content-server/server/lib/routes/get-index.js +++ b/packages/fxa-content-server/server/lib/routes/get-index.js @@ -59,6 +59,9 @@ module.exports = function (config) { const GLEAN_LOG_PINGS = config.get('glean.logPings'); const GLEAN_DEBUG_VIEW_TAG = config.get('glean.debugViewTag'); + // Rather than relay all rollout rates, hand pick the ones that are applicable + const ROLLOUT_RATES = config.get('rolloutRates'); + // Note that this list is only enforced for clients that use login_hint/email // with prompt=none. id_token_hint clients are not subject to this check. const PROMPT_NONE_ENABLED_CLIENT_IDS = new Set( @@ -89,6 +92,7 @@ module.exports = function (config) { profileUrl: PROFILE_SERVER_URL, release: RELEASE, redirectAllowlist: REDIRECT_CHECK_ALLOW_LIST, + rolloutRates: ROLLOUT_RATES, scopedKeysEnabled: SCOPED_KEYS_ENABLED, scopedKeysValidation: SCOPED_KEYS_VALIDATION, sentry: { diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index cc329ee6d4..b5b1ea03d9 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -82,6 +82,12 @@ export interface Config { signUpRoutes: boolean; signInRoutes: boolean; }; + rolloutRates?: { + keyStretchV2?: number; + }; + featureFlags?: { + keyStretchV2?: boolean; + }; } export function getDefault() { diff --git a/packages/fxa-settings/src/models/contexts/AppContext.ts b/packages/fxa-settings/src/models/contexts/AppContext.ts index f166d300fb..cc1d2c56de 100644 --- a/packages/fxa-settings/src/models/contexts/AppContext.ts +++ b/packages/fxa-settings/src/models/contexts/AppContext.ts @@ -38,7 +38,7 @@ export function initializeAppContext() { new UrlQueryData(new ReachRouterWindow()) ); const authClient = new AuthClient(config.servers.auth.url, { - keyStretchVersion: keyStretchExperiment.isV2() ? 2 : 1 + keyStretchVersion: keyStretchExperiment.isV2(config) ? 2 : 1, }); const apolloClient = createApolloClient(config.servers.gql.url); const account = new Account(authClient, apolloClient); diff --git a/packages/fxa-settings/src/models/experiments/key-stretch-experiment.test.ts b/packages/fxa-settings/src/models/experiments/key-stretch-experiment.test.ts new file mode 100644 index 0000000000..4d58d1fc70 --- /dev/null +++ b/packages/fxa-settings/src/models/experiments/key-stretch-experiment.test.ts @@ -0,0 +1,64 @@ +/* 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 { GenericData } from '../../lib/model-data'; +import { ReachRouterWindow } from '../../lib/window'; +import { KeyStretchExperiment } from './key-stretch-experiment'; + +describe('Key Stretch Experiment Model', function () { + const ffOn = { + featureFlags: { + keyStretchV2: true, + }, + }; + + const ffOff = { + featureFlags: { + keyStretchV2: false, + }, + }; + + const window = new ReachRouterWindow(); + let model: KeyStretchExperiment; + beforeEach(function () { + model = new KeyStretchExperiment(new GenericData({})); + }); + + afterEach(() => { + window.localStorage.removeItem(`__fxa_storage.experiment.keyStretchV2`); + }); + + it('is disabled by default', () => { + const model = new KeyStretchExperiment(new GenericData({})); + expect(model.isV2(ffOn)).toBeFalsy(); + expect(model.isV2(ffOff)).toBeFalsy(); + }); + + it('enables with stretch query parameter', () => { + const model = new KeyStretchExperiment(new GenericData({ stretch: '2' })); + expect(model.isV2(ffOn)).toBeTruthy(); + expect(model.isV2(ffOff)).toBeFalsy(); + }); + + it('enables with force experiment query parameters', () => { + const model = new KeyStretchExperiment( + new GenericData({ + forceExperiment: 'keyStretchV2', + forceExperimentGroup: 'v2', + }) + ); + expect(model.isV2(ffOn)).toBeTruthy(); + expect(model.isV2(ffOff)).toBeFalsy(); + }); + + it('enables with content-server experiment', () => { + window.localStorage.setItem( + `__fxa_storage.experiment.keyStretchV2`, + JSON.stringify(JSON.stringify({ enrolled: true })) + ); + const model = new KeyStretchExperiment(new GenericData({})); + expect(model.isV2(ffOn)).toBeTruthy(); + expect(model.isV2(ffOff)).toBeFalsy(); + }); +}); diff --git a/packages/fxa-settings/src/models/experiments/key-stretch-experiment.ts b/packages/fxa-settings/src/models/experiments/key-stretch-experiment.ts index 8d151199b0..8d6bfdae88 100644 --- a/packages/fxa-settings/src/models/experiments/key-stretch-experiment.ts +++ b/packages/fxa-settings/src/models/experiments/key-stretch-experiment.ts @@ -4,6 +4,7 @@ import { IsOptional, IsString } from 'class-validator'; import { bind, ModelDataProvider } from '../../lib/model-data'; +import * as Sentry from '@sentry/browser'; export class KeyStretchExperiment extends ModelDataProvider { @IsOptional() @@ -21,11 +22,68 @@ export class KeyStretchExperiment extends ModelDataProvider { @bind() forceExperimentGroup?: string; - isV2() { - return ( - this.stretch === '2' || - (this.forceExperiment === 'key-stretch' && - this.forceExperimentGroup === 'v2') - ); + isV2(config: { featureFlags?: { keyStretchV2?: boolean } }) { + // If the feature flag is off or unspecified, always disable the functionality! + if (!config.featureFlags?.keyStretchV2) { + return false; + } + + // If stretch=2 in the URL, then force V2 key stretching for this page render, + // This is used for dev/test purposes. + if (this.stretch === '2') { + return true; + } + + // If force experiment params are in URL, then force V2 key stretching, and + // automatically enroll in experiment, so that content server will pick it up. + if ( + this.forceExperiment === 'keyStretchV2' && + this.forceExperimentGroup === 'v2' + ) { + enrollInExp('keyStretchV2', true); + return true; + } + + if (isEnrolledIn('keyStretchV2')) { + return true; + } + + // Typical state. Not enrolled and not using V2 key stretching. + return false; + } +} + +/** + * Sets state for a local experiment. Typically this is set by + * content-server/backbone, but we are in the midst of a migration to + * react. So for now we will sort of go behind the content server's back + * here. Note, that this will only happen if force experiment query params + * are set. Otherwise, the experiment will not be activated, or have to be + * activated by a content server / backbone page. + */ +function enrollInExp(key: string, enrolled: boolean) { + window.localStorage.setItem( + `__fxa_storage.experiment.${key}`, + JSON.stringify(JSON.stringify({ enrolled })) + ); +} + +/** + * Check if local storage indicates an active key stretch experiment. + * This is set by content-server/backbone. In the future we will be + * porting over experimentation code into settings, but this is in flux + * at the moment. See FXA-9183 for more info and latest status + */ +function isEnrolledIn(key: string) { + try { + let value: any = window.localStorage.getItem( + `__fxa_storage.experiment.${key}` + ); + const json = value ? JSON.parse(JSON.parse(value)) : { enrolled: false }; + return json.enrolled === true; + } catch (error) { + Sentry.captureException(error); + // If value was malformed then assume false. + return false; } } diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx index b50996f10e..f743f7a80e 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx @@ -15,6 +15,7 @@ import { useFtlMsgResolver, isSyncDesktopV3Integration, isSyncOAuthIntegration, + useConfig, } from '../../../models'; import WarningMessage from '../../../components/WarningMessage'; import LinkRememberPassword from '../../../components/LinkRememberPassword'; @@ -71,6 +72,7 @@ const CompleteResetPassword = ({ const navigate = useNavigate(); const navigateWithoutRerender = useNavigateWithoutRerender(); const keyStretchExperiment = useValidatedQueryParams(KeyStretchExperiment); + const config = useConfig(); const account = useAccount(); const location = useLocation() as ReturnType & { state: CompleteResetPasswordLocationState; @@ -217,7 +219,7 @@ const CompleteResetPassword = ({ GleanMetrics.resetPassword.createNewSubmit(); const accountResetData = await account.completeResetPassword( - keyStretchExperiment.queryParamModel.isV2(), + keyStretchExperiment.queryParamModel.isV2(config), token, code, emailToUse, @@ -268,6 +270,7 @@ const CompleteResetPassword = ({ ftlMsgResolver, setLinkStatus, keyStretchExperiment, + config, ] ); diff --git a/packages/fxa-settings/src/pages/Signin/container.tsx b/packages/fxa-settings/src/pages/Signin/container.tsx index 434dde6487..2e4ad8a5f6 100644 --- a/packages/fxa-settings/src/pages/Signin/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/container.tsx @@ -9,6 +9,7 @@ import { isSyncDesktopV3Integration, useAuthClient, useFtlMsgResolver, + useConfig, } from '../../models'; import { MozServices } from '../../lib/types'; import { useValidatedQueryParams } from '../../lib/hooks/useValidate'; @@ -82,6 +83,7 @@ const SigninContainer = ({ integration: SigninContainerIntegration; serviceName: MozServices; } & RouteComponentProps) => { + const config = useConfig(); const authClient = useAuthClient(); const ftlMsgResolver = useFtlMsgResolver(); const navigate = useNavigate(); @@ -205,7 +207,7 @@ const SigninContainer = ({ const v1Credentials = await getCredentials(email, password); let v2Credentials = null; - if (keyStretchExp.queryParamModel.isV2()) { + if (keyStretchExp.queryParamModel.isV2(config)) { const credentialStatusData = await credentialStatus({ variables: { input: email, @@ -355,6 +357,7 @@ const SigninContainer = ({ keyStretchExp.queryParamModel, passwordChangeFinish, passwordChangeStart, + config, ] ); diff --git a/packages/fxa-settings/src/pages/Signup/container.tsx b/packages/fxa-settings/src/pages/Signup/container.tsx index 70f05ba6bd..43709bca46 100644 --- a/packages/fxa-settings/src/pages/Signup/container.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.tsx @@ -205,7 +205,7 @@ const SignupContainer = ({ // If enabled, add in V2 key stretching support let credentialsV2 = undefined; let passwordV2 = undefined; - if (keyStretchExp.queryParamModel.isV2()) { + if (keyStretchExp.queryParamModel.isV2(config)) { credentialsV2 = await getCredentialsV2({ password, clientSalt: await createSaltV2(), @@ -258,7 +258,7 @@ const SignupContainer = ({ return handleGQLError(error); } }, - [beginSignup, integration, isSyncDesktopV3, isOAuth, keyStretchExp] + [beginSignup, integration, isSyncDesktopV3, isOAuth, keyStretchExp, config] ); // TODO: probably a better way to read this?