diff --git a/app/components/webauthn_input_component.rb b/app/components/webauthn_input_component.rb index 8c2b952b5f6..f029d9f1c8f 100644 --- a/app/components/webauthn_input_component.rb +++ b/app/components/webauthn_input_component.rb @@ -1,21 +1,25 @@ # frozen_string_literal: true class WebauthnInputComponent < BaseComponent - attr_reader :platform, :passkey_supported_only, :show_unsupported_passkey, :tag_options + attr_reader :platform, :passkey_supported_only, :show_unsupported_passkey, + :desktop_ft_unlock_option, :tag_options alias_method :platform?, :platform alias_method :passkey_supported_only?, :passkey_supported_only alias_method :show_unsupported_passkey?, :show_unsupported_passkey + alias_method :desktop_ft_unlock_option?, :desktop_ft_unlock_option def initialize( platform: false, passkey_supported_only: false, show_unsupported_passkey: false, + desktop_ft_unlock_option: false, **tag_options ) @platform = platform @passkey_supported_only = passkey_supported_only @show_unsupported_passkey = show_unsupported_passkey + @desktop_ft_unlock_option = desktop_ft_unlock_option @tag_options = tag_options end @@ -26,6 +30,7 @@ def call **tag_options, **initial_hidden_tag_options, 'show-unsupported-passkey': show_unsupported_passkey?.presence, + 'desktop-ft-unlock-option': show_desktop_ft_unlock_option?.presence, ) end @@ -36,4 +41,8 @@ def initial_hidden_tag_options { class: 'js' } end end + + def show_desktop_ft_unlock_option? + desktop_ft_unlock_option? && I18n.locale == :en + end end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 34b5370f2ce..3bfe5e83455 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -5,6 +5,7 @@ class TwoFactorAuthenticationSetupController < ApplicationController include UserAuthenticator include MfaSetupConcern include AbTestingConcern + include ApplicationHelper before_action :authenticate_user before_action :confirm_user_authenticated_for_2fa_setup @@ -68,6 +69,7 @@ def two_factor_options_presenter show_skip_additional_mfa_link: show_skip_additional_mfa_link?, after_mfa_setup_path:, return_to_sp_cancel_path:, + desktop_ft_ab_test: in_ab_test_bucket?, ) end @@ -81,5 +83,9 @@ def two_factor_options_form_params rescue ActionController::ParameterMissing ActionController::Parameters.new(selection: []) end + + def in_ab_test_bucket? + ab_test_bucket(:DESKTOP_FT_UNLOCK_SETUP) == (:desktop_ft_unlock_option_shown) + end end end diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index b67f5b04275..b7ae7912f5a 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -88,7 +88,7 @@ def validate_existing_platform_authenticator if platform_authenticator? && in_account_creation_flow? && current_user.webauthn_configurations.platform_authenticators.present? redirect_to authentication_methods_setup_path - end + end end def webauthn_auth_method diff --git a/app/javascript/packages/webauthn/webauthn-input-element.spec.ts b/app/javascript/packages/webauthn/webauthn-input-element.spec.ts index afd363cc143..641058c2ec3 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.spec.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.spec.ts @@ -40,6 +40,20 @@ describe('WebauthnInputElement', () => { }); }); + context('as a part of A/B test', () => { + beforeEach(() => { + isWebauthnPasskeySupported.returns(false); + isWebauthnPlatformAvailable.resolves(true); + document.body.innerHTML = ``; + }); + + it('becomes visible', async () => { + const element = document.querySelector('lg-webauthn-input')!; + + await waitFor(() => expect(element.hidden).to.be.false()); + }); + }); + context('unsupported passkey shown', () => { beforeEach(() => { isWebauthnPasskeySupported.returns(false); diff --git a/app/javascript/packages/webauthn/webauthn-input-element.ts b/app/javascript/packages/webauthn/webauthn-input-element.ts index 954cac625c0..a7e9759b6c4 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.ts @@ -1,11 +1,15 @@ -import isWebauthnPasskeySupported from './is-webauthn-passkey-supported'; import isWebauthnPlatformAuthenticatorAvailable from './is-webauthn-platform-authenticator-available'; +import isWebauthnPasskeySupported from './is-webauthn-passkey-supported'; export class WebauthnInputElement extends HTMLElement { connectedCallback() { this.toggleVisibleIfPasskeySupported(); } + get isOptedInToAbTest(): boolean { + return this.hasAttribute('desktop-ft-unlock-option'); + } + get isPlatform(): boolean { return this.hasAttribute('platform'); } @@ -19,7 +23,10 @@ export class WebauthnInputElement extends HTMLElement { return; } - if (isWebauthnPasskeySupported() && (await isWebauthnPlatformAuthenticatorAvailable())) { + if ( + (isWebauthnPasskeySupported() || this.isOptedInToAbTest) && + (await isWebauthnPlatformAuthenticatorAvailable()) + ) { this.hidden = false; } else if (this.showUnsupportedPasskey) { this.hidden = false; diff --git a/app/presenters/two_factor_authentication/set_up_selection_presenter.rb b/app/presenters/two_factor_authentication/set_up_selection_presenter.rb index 798cd289c67..7942bcfc260 100644 --- a/app/presenters/two_factor_authentication/set_up_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/set_up_selection_presenter.rb @@ -4,7 +4,11 @@ module TwoFactorAuthentication class SetUpSelectionPresenter include ActionView::Helpers::TranslationHelper - attr_reader :user, :piv_cac_required, :phishing_resistant_required, :user_agent + attr_reader :user, + :piv_cac_required, + :phishing_resistant_required, + :user_agent, + :desktop_ft_ab_test alias_method :piv_cac_required?, :piv_cac_required alias_method :phishing_resistant_required?, :phishing_resistant_required @@ -12,12 +16,14 @@ def initialize( user:, piv_cac_required: false, phishing_resistant_required: false, - user_agent: nil + user_agent: nil, + desktop_ft_ab_test: nil ) @user = user @piv_cac_required = piv_cac_required @phishing_resistant_required = phishing_resistant_required @user_agent = user_agent + @desktop_ft_ab_test = desktop_ft_ab_test end def render_in(view_context, &block) diff --git a/app/presenters/two_factor_authentication/set_up_webauthn_platform_selection_presenter.rb b/app/presenters/two_factor_authentication/set_up_webauthn_platform_selection_presenter.rb index a4fef5d7285..1e7ff563da8 100644 --- a/app/presenters/two_factor_authentication/set_up_webauthn_platform_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/set_up_webauthn_platform_selection_presenter.rb @@ -13,6 +13,7 @@ def render_in(view_context, &block) passkey_supported_only: true, show_unsupported_passkey: IdentityConfig.store.show_unsupported_passkey_platform_authentication_setup, + desktop_ft_unlock_option: desktop_ft_ab_test, ), &block ) diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb index 3477afbd009..1dc080b59e4 100644 --- a/app/presenters/two_factor_options_presenter.rb +++ b/app/presenters/two_factor_options_presenter.rb @@ -8,7 +8,8 @@ class TwoFactorOptionsPresenter :return_to_sp_cancel_path, :phishing_resistant_required, :piv_cac_required, - :user_agent + :user_agent, + :desktop_ft_ab_test delegate :two_factor_enabled?, to: :mfa_policy def initialize( @@ -18,7 +19,8 @@ def initialize( piv_cac_required: false, show_skip_additional_mfa_link: true, after_mfa_setup_path: nil, - return_to_sp_cancel_path: nil + return_to_sp_cancel_path: nil, + desktop_ft_ab_test: false ) @user_agent = user_agent @user = user @@ -27,6 +29,7 @@ def initialize( @show_skip_additional_mfa_link = show_skip_additional_mfa_link @after_mfa_setup_path = after_mfa_setup_path @return_to_sp_cancel_path = return_to_sp_cancel_path + @desktop_ft_ab_test = desktop_ft_ab_test end def options @@ -47,6 +50,7 @@ def all_options_sorted piv_cac_required: piv_cac_required?, phishing_resistant_required: phishing_resistant_only?, user_agent:, + desktop_ft_ab_test:, ) end. partition(&:recommended?). diff --git a/config/application.yml.default b/config/application.yml.default index 81b1cc3ebcc..7a76d98eed1 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -89,6 +89,7 @@ database_worker_jobs_sslmode: 'verify-full' database_worker_jobs_username: '' deleted_user_accounts_report_configs: '[]' deliver_mail_async: false +desktop_ft_unlock_setup_option_percent_tested: 0 development_mailer_deliver_method: letter_opener disable_email_sending: true disable_logout_get_request: true @@ -457,6 +458,7 @@ development: compromised_password_randomizer_value: 1 dashboard_api_token: test_token dashboard_url: http://localhost:3001/api/service_providers + desktop_ft_unlock_setup_option_percent_tested: 100 doc_auth_selfie_desktop_test_mode: true domain_name: localhost:3000 enable_rate_limiting: false diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index fff91a2dd65..c4a4cce8924 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -104,4 +104,15 @@ def self.all shadow_mode_enabled: IdentityConfig.store.socure_idplus_shadow_mode_percent, }, ).freeze + + DESKTOP_FT_UNLOCK_SETUP = AbTest.new( + experiment_name: 'Desktop F/T unlock setup', + should_log: [ + 'User Registration: 2FA Setup visited', + :webauthn_setup_submitted, + 'Multi-Factor Authentication Setup', + ].to_set, + buckets: { desktop_ft_unlock_option_shown: + IdentityConfig.store.desktop_ft_unlock_setup_option_percent_tested }, + ).freeze end diff --git a/lib/identity_config.rb b/lib/identity_config.rb index d85fbef073d..f8a5467d2af 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -107,6 +107,7 @@ def self.store config.add(:database_worker_jobs_username, type: :string) config.add(:deleted_user_accounts_report_configs, type: :json) config.add(:deliver_mail_async, type: :boolean) + config.add(:desktop_ft_unlock_setup_option_percent_tested, type: :integer) config.add(:development_mailer_deliver_method, type: :symbol, enum: [:file, :letter_opener]) config.add(:disable_email_sending, type: :boolean) config.add(:disable_logout_get_request, type: :boolean) diff --git a/spec/components/webauthn_input_component_spec.rb b/spec/components/webauthn_input_component_spec.rb index 2e9f99d9931..5212698d7e9 100644 --- a/spec/components/webauthn_input_component_spec.rb +++ b/spec/components/webauthn_input_component_spec.rb @@ -17,8 +17,26 @@ expect(component.passkey_supported_only?).to eq(false) end - it 'exposes boolean alias for show_unsupported_passkey option' do - expect(component.show_unsupported_passkey?).to eq(false) + it 'does not render desktop-ft-unlock-option attribute' do + expect(rendered).to have_css('lg-webauthn-input:not([desktop-ft-unlock-option="false"])') + end + + context 'with desktop_ft_unlock_option' do + let(:options) { super().merge(desktop_ft_unlock_option: true) } + + it 'does render desktop-ft-unlock-option attribute' do + expect(rendered).to have_css('lg-webauthn-input[desktop-ft-unlock-option="true"]') + end + + context 'in a locale other than english' do + before do + I18n.locale = I18n.available_locales.sample + end + + it 'does not render desktop-ft-unlock-option attribute' do + expect(rendered).to have_css('lg-webauthn-input:not([desktop-ft-unlock-option="false"])') + end + end end context 'with platform option' do diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb index 8c14e6f0924..724b71e32df 100644 --- a/spec/config/initializers/ab_tests_spec.rb +++ b/spec/config/initializers/ab_tests_spec.rb @@ -220,7 +220,7 @@ end end - describe '.RECOMMEND_WEBAUTHN_PLATFORM_FOR_SMS_USER' do + describe 'RECOMMEND_WEBAUTHN_PLATFORM_FOR_SMS_USER' do let(:user) { create(:user) } subject(:bucket) do @@ -301,4 +301,49 @@ end end end + + describe 'DESKTOP_FT_UNLOCK_SETUP' do + let(:user) { nil } + let(:user_session) { {} } + + subject(:bucket) do + AbTests::DESKTOP_FT_UNLOCK_SETUP.bucket( + request: nil, + service_provider: nil, + session: nil, + user:, + user_session:, + ) + end + + context 'when A/B test is disabled' do + before do + allow(IdentityConfig.store).to receive(:desktop_ft_unlock_setup_option_percent_tested). + and_return(0) + reload_ab_tests + end + + context 'when it would otherwise assign a bucket' do + let(:user) { build(:user) } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + end + + context 'when A/B test is enabled' do + before do + allow(IdentityConfig.store).to receive(:desktop_ft_unlock_setup_option_percent_tested). + and_return(100) + reload_ab_tests + end + + let(:user) { build(:user) } + + it 'returns a bucket' do + expect(bucket).not_to be_nil + end + end + end end diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 1cc8d62a217..574627a8860 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -4,6 +4,8 @@ describe 'GET index' do let(:user) { create(:user) } + subject(:response) { get :index } + before do stub_sign_in_before_2fa(user) if user stub_analytics @@ -19,6 +21,12 @@ ) end + it 'initializes presenter with false ab test bucket value' do + response + + expect(assigns(:presenter).desktop_ft_ab_test).to be false + end + context 'with user having gov or mil email' do let!(:federal_domain) { create(:federal_email_domain, name: 'gsa.gov') } let(:user) do @@ -101,6 +109,20 @@ expect(response).to redirect_to(user_two_factor_authentication_url) end end + + context 'with user opted in to desktop ft unlock setup ab test' do + before do + allow(controller).to receive(:ab_test_bucket).with( + :DESKTOP_FT_UNLOCK_SETUP, + ).and_return(:desktop_ft_unlock_option_shown) + end + + it 'initializes presenter with ab test bucket value' do + response + + expect(assigns(:presenter).desktop_ft_ab_test).to eq(true) + end + end end describe '#create' do diff --git a/spec/features/webauthn/hidden_spec.rb b/spec/features/webauthn/hidden_spec.rb index 57b52cbb92b..0c0ea800316 100644 --- a/spec/features/webauthn/hidden_spec.rb +++ b/spec/features/webauthn/hidden_spec.rb @@ -3,6 +3,7 @@ RSpec.describe 'webauthn hide' do include JavascriptDriverHelper include WebAuthnHelper + include AbTestsHelper describe 'security key' do let(:option_id) { 'two_factor_options_form_selection_webauthn' } @@ -59,6 +60,36 @@ expect(webauthn_option_hidden?).to eq(true) end + context 'when in ab test for desktop setup' do + before do + allow(IdentityConfig.store).to receive(:desktop_ft_unlock_setup_option_percent_tested). + and_return(100) + reload_ab_tests + end + + it 'displays the authenticator option' do + sign_up_and_set_password + simulate_platform_authenticator_available + + expect(webauthn_option_hidden?).to eq(false) + end + end + + context 'when A/B test is disabled' do + before do + allow(IdentityConfig.store).to receive(:desktop_ft_unlock_setup_option_percent_tested). + and_return(0) + reload_ab_tests + end + + it 'hides the authenticator option' do + sign_up_and_set_password + simulate_platform_authenticator_available + + expect(webauthn_option_hidden?).to eq(true) + end + end + context 'with supported browser and platform authenticator available', driver: :headless_chrome_mobile do it 'displays the authenticator option' do