diff --git a/Gemfile.lock b/Gemfile.lock index 3b633f9e792..2c21093b0ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -567,12 +567,12 @@ GEM rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) rspec-mocks (~> 3.12.0) - rspec-core (3.12.0) + rspec-core (3.12.2) rspec-support (~> 3.12.0) - rspec-expectations (3.12.2) + rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-mocks (3.12.2) + rspec-mocks (3.12.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-rails (6.0.1) diff --git a/app/components/webauthn_input_component.rb b/app/components/webauthn_input_component.rb index f5e6eb0d4f7..25e0aefdcb4 100644 --- a/app/components/webauthn_input_component.rb +++ b/app/components/webauthn_input_component.rb @@ -1,10 +1,12 @@ class WebauthnInputComponent < BaseComponent - attr_reader :platform, :tag_options + attr_reader :platform, :passkey_supported_only, :tag_options alias_method :platform?, :platform + alias_method :passkey_supported_only?, :passkey_supported_only - def initialize(platform: false, **tag_options) + def initialize(platform: false, passkey_supported_only: false, **tag_options) @platform = platform + @passkey_supported_only = passkey_supported_only @tag_options = tag_options end @@ -15,6 +17,7 @@ def call **tag_options, hidden: true, platform: platform.presence, + 'passkey-supported-only': passkey_supported_only.presence, ) end end diff --git a/app/javascript/packages/webauthn/is-webauthn-passkey-supported.spec.ts b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.spec.ts new file mode 100644 index 00000000000..c9e7e6bd39a --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.spec.ts @@ -0,0 +1,104 @@ +import { useDefineProperty } from '@18f/identity-test-helpers'; +import isWebauthnPasskeySupported from './is-webauthn-passkey-supported'; + +// Source (Adapted): https://www.chromium.org/updates/ua-reduction/#sample-ua-strings-final-reduced-state +const UNSUPPORTED_ANDROID_VERSION_UA = + 'Mozilla/5.0 (Linux; Android 8; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.1234.56 Mobile Safari/537.36'; + +// Source: https://www.chromium.org/updates/ua-reduction/#sample-ua-strings-final-reduced-state +const REDUCED_UNSUPPORTED_ANDROID_VERSION_UA = + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Mobile Safari/537.36'; + +// Source: https://www.chromium.org/updates/ua-reduction/#sample-ua-strings-final-reduced-state +const SUPPORTED_ANDROID_VERSION_CHROME_UA = + 'Mozilla/5.0 (Linux; Android 9; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.1234.56 Mobile Safari/537.36'; + +// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent/Firefox#mobile_and_tablet_indicators +const SUPPORTED_ANDROID_VERSION_FIREFOX_UA = + 'Mozilla/5.0 (Android 9; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0'; + +// Source: https://chromium.googlesource.com/chromium/src/+/master/docs/ios/user_agent.md +const UNSUPPORTED_IOS_VERSION_CHROME_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1'; + +// Source: https://chromium.googlesource.com/chromium/src/+/master/docs/ios/user_agent.md +const UNSUPPORTED_IOS_VERSION_SAFARI_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/603.1.23 (KHTML, like Gecko) Version/10.0 Mobile/14E5239e Safari/602.1'; + +// Source (Adapted): https://chromium.googlesource.com/chromium/src/+/master/docs/ios/user_agent.md +const SUPPORTED_IOS_CHROME_VERSION_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1'; + +// Source (Adapted): https://chromium.googlesource.com/chromium/src/+/master/docs/ios/user_agent.md +const SUPPORTED_IOS_SAFARI_VERSION_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/603.1.23 (KHTML, like Gecko) Version/10.0 Mobile/14E5239e Safari/602.1'; + +// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent#firefox_ua_string +const FIREFOX_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0'; + +// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent#chrome_ua_string +const DESKTOP_CHROME_UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36'; + +// Source: Me +const DESKTOP_SAFARI_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15'; + +const SUPPORTED_USER_AGENTS = [ + REDUCED_UNSUPPORTED_ANDROID_VERSION_UA, + SUPPORTED_ANDROID_VERSION_CHROME_UA, + SUPPORTED_IOS_CHROME_VERSION_UA, + SUPPORTED_IOS_SAFARI_VERSION_UA, +]; + +const UNSUPPORTED_USER_AGENTS = [ + UNSUPPORTED_ANDROID_VERSION_UA, + SUPPORTED_ANDROID_VERSION_FIREFOX_UA, + UNSUPPORTED_IOS_VERSION_CHROME_UA, + UNSUPPORTED_IOS_VERSION_SAFARI_UA, + FIREFOX_UA, + DESKTOP_CHROME_UA, + DESKTOP_SAFARI_UA, +]; + +describe('isWebauthnPasskeySupported', () => { + const defineProperty = useDefineProperty(); + + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, + }); + }); + + UNSUPPORTED_USER_AGENTS.forEach((userAgent) => { + context(userAgent, () => { + beforeEach(() => { + defineProperty(navigator, 'userAgent', { + configurable: true, + value: userAgent, + }); + }); + + it('resolves to false', () => { + expect(isWebauthnPasskeySupported()).to.equal(false); + }); + }); + }); + + SUPPORTED_USER_AGENTS.forEach((userAgent) => { + context(userAgent, () => { + beforeEach(() => { + defineProperty(navigator, 'userAgent', { + configurable: true, + value: userAgent, + }); + }); + + it('resolves to true', () => { + expect(isWebauthnPasskeySupported()).to.equal(true); + }); + }); + }); +}); diff --git a/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts new file mode 100644 index 00000000000..b496028322b --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts @@ -0,0 +1,30 @@ +export type IsWebauthnPasskeySupported = () => boolean; + +const MINIMUM_IOS_VERSION = 16; + +const MINIMUM_ANDROID_VERSION = 9; + +function isQualifyingIOSDevice(): boolean { + const match = navigator.userAgent.match(/iPhone; CPU iPhone OS (\d+)_/); + const iOSVersion: null | number = match && Number(match[1]); + return !!iOSVersion && iOSVersion >= MINIMUM_IOS_VERSION; +} + +function isQualifyingAndroidDevice(): boolean { + // Note: Chrome versions applying the "reduced" user agent string will always report a version of + // Android as 10.0.0. + // + // See: https://www.chromium.org/updates/ua-reduction/ + const match = navigator.userAgent.match(/; Android (\d+)/); + const androidVersion: null | number = match && Number(match[1]); + return ( + !!androidVersion && + androidVersion >= MINIMUM_ANDROID_VERSION && + navigator.userAgent.includes(' Chrome/') + ); +} + +const isWebauthnPasskeySupported: IsWebauthnPasskeySupported = () => + isQualifyingIOSDevice() || isQualifyingAndroidDevice(); + +export default isWebauthnPasskeySupported; diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts b/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts index 5f1aec6714e..5e1957f6c5c 100644 --- a/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts +++ b/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts @@ -39,7 +39,7 @@ describe('isWebauthnPlatformSupported', () => { }); }); - it('resolves to false', async () => { + it('resolves to true', async () => { await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); }); }); diff --git a/app/javascript/packages/webauthn/webauth-input-element.spec.ts b/app/javascript/packages/webauthn/webauth-input-element.spec.ts index 6a8c217b750..89816331c9c 100644 --- a/app/javascript/packages/webauthn/webauth-input-element.spec.ts +++ b/app/javascript/packages/webauthn/webauth-input-element.spec.ts @@ -2,6 +2,7 @@ import sinon from 'sinon'; import quibble from 'quibble'; import { waitFor } from '@testing-library/dom'; import type { IsWebauthnSupported } from './is-webauthn-supported'; +import type { IsWebauthnPasskeySupported } from './is-webauthn-passkey-supported'; import type { IsWebauthnPlatformSupported } from './is-webauthn-platform-supported'; describe('WebauthnInputElement', () => { @@ -9,6 +10,10 @@ describe('WebauthnInputElement', () => { Parameters, ReturnType >(); + const isWebauthnPasskeySupported = sinon.stub< + Parameters, + ReturnType + >(); const isWebauthnPlatformSupported = sinon.stub< Parameters, ReturnType @@ -16,6 +21,7 @@ describe('WebauthnInputElement', () => { before(async () => { quibble('./is-webauthn-supported', isWebauthnSupported); + quibble('./is-webauthn-passkey-supported', isWebauthnPasskeySupported); quibble('./is-webauthn-platform-supported', isWebauthnPlatformSupported); await import('./webauthn-input-element'); }); @@ -23,6 +29,8 @@ describe('WebauthnInputElement', () => { beforeEach(() => { isWebauthnSupported.reset(); isWebauthnSupported.returns(false); + isWebauthnPasskeySupported.reset(); + isWebauthnPasskeySupported.resolves(false); isWebauthnPlatformSupported.reset(); isWebauthnPlatformSupported.resolves(false); }); @@ -54,32 +62,25 @@ describe('WebauthnInputElement', () => { document.body.innerHTML = ``; }); - it('becomes visible', () => { + it('becomes visible', async () => { const element = document.querySelector('lg-webauthn-input')!; - expect(element.hidden).to.be.false(); + await waitFor(() => expect(element.hidden).to.be.false()); }); }); context('input for platform authenticator', () => { context('device does not have available platform authenticator', () => { - let resolveIsWebauthnPlatformSupported; - beforeEach(() => { - isWebauthnPlatformSupported.callsFake(() => { - resolveIsWebauthnPlatformSupported = new Promise((resolve) => { - resolve(false); - }); - return resolveIsWebauthnPlatformSupported; - }); + isWebauthnPlatformSupported.resolves(false); document.body.innerHTML = ``; }); it('stays hidden', async () => { - await expect(resolveIsWebauthnPlatformSupported).to.eventually.equal(false); - const element = document.querySelector('lg-webauthn-input')!; + await waitFor(() => expect(element.isInitialized).to.be.true()); + expect(element.hidden).to.be.true(); }); }); @@ -96,6 +97,38 @@ describe('WebauthnInputElement', () => { await waitFor(() => expect(element.hidden).to.be.false()); }); }); + + context('passkey supported only', () => { + context('device does not support passkey', () => { + beforeEach(() => { + isWebauthnPlatformSupported.resolves(true); + isWebauthnPasskeySupported.returns(false); + document.body.innerHTML = ``; + }); + + it('stays hidden', async () => { + const element = document.querySelector('lg-webauthn-input')!; + + await waitFor(() => expect(element.isInitialized).to.be.true()); + + expect(element.hidden).to.be.true(); + }); + }); + + context('device supports passkey', () => { + beforeEach(() => { + isWebauthnPlatformSupported.resolves(true); + isWebauthnPasskeySupported.returns(true); + document.body.innerHTML = ``; + }); + + it('becomes visible', async () => { + const element = document.querySelector('lg-webauthn-input')!; + + await waitFor(() => expect(element.hidden).to.be.false()); + }); + }); + }); }); }); }); diff --git a/app/javascript/packages/webauthn/webauthn-input-element.ts b/app/javascript/packages/webauthn/webauthn-input-element.ts index 576338379c2..5d7e5327d09 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.ts @@ -1,17 +1,41 @@ +import isWebauthnPasskeySupported from './is-webauthn-passkey-supported'; import isWebauthnPlatformSupported from './is-webauthn-platform-supported'; import isWebauthnSupported from './is-webauthn-supported'; export class WebauthnInputElement extends HTMLElement { - connectedCallback() { - this.toggleVisibleIfSupported(); + isInitialized = false; + + async connectedCallback() { + await this.toggleVisibleIfSupported(); + this.isInitialized = true; } get isPlatform(): boolean { return this.hasAttribute('platform'); } + get isOnlyPasskeySupported(): boolean { + return this.hasAttribute('passkey-supported-only'); + } + + async isSupported(): Promise { + if (!isWebauthnSupported()) { + return false; + } + + if (!this.isPlatform) { + return true; + } + + if (!(await isWebauthnPlatformSupported())) { + return false; + } + + return !this.isOnlyPasskeySupported || isWebauthnPasskeySupported(); + } + async toggleVisibleIfSupported() { - if (isWebauthnSupported() && (!this.isPlatform || (await isWebauthnPlatformSupported()))) { + if (await this.isSupported()) { this.removeAttribute('hidden'); } } diff --git a/app/presenters/two_factor_authentication/webauthn_platform_selection_presenter.rb b/app/presenters/two_factor_authentication/webauthn_platform_selection_presenter.rb index eba1d8c9713..6bc019f6e2c 100644 --- a/app/presenters/two_factor_authentication/webauthn_platform_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/webauthn_platform_selection_presenter.rb @@ -5,7 +5,10 @@ def method end def render_in(view_context, &block) - view_context.render(WebauthnInputComponent.new(platform: true), &block) + view_context.render( + WebauthnInputComponent.new(platform: true, passkey_supported_only: configuration.blank?), + &block + ) end def disabled? diff --git a/spec/components/webauthn_input_component_spec.rb b/spec/components/webauthn_input_component_spec.rb index 433b16a33c2..be26bf8c1e8 100644 --- a/spec/components/webauthn_input_component_spec.rb +++ b/spec/components/webauthn_input_component_spec.rb @@ -13,6 +13,10 @@ expect(component.platform?).to eq(false) end + it 'exposes boolean alias for passkey_supported_only option' do + expect(component.passkey_supported_only?).to eq(false) + end + context 'with platform option' do context 'with platform option false' do let(:options) { { platform: false } } @@ -31,6 +35,30 @@ end end + context 'with passkey_supported_only option' do + context 'with passkey_supported_only option false' do + let(:options) { { passkey_supported_only: false } } + + it 'renders without passkey-supported-only attribute' do + expect(rendered).to have_css( + 'lg-webauthn-input[hidden]:not([passkey-supported-only])', + visible: false, + ) + end + end + + context 'with passkey_supported_only option true' do + let(:options) { { passkey_supported_only: true } } + + it 'renders with passkey-supported-only attribute' do + expect(rendered).to have_css( + 'lg-webauthn-input[hidden][passkey-supported-only]', + visible: false, + ) + end + end + end + context 'with tag options' do let(:options) { super().merge(data: { foo: 'bar' }) } diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 7763d2b5406..f85f49e0883 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -544,7 +544,7 @@ def attempt_to_bypass_2fa allow(IdentityConfig.store).to receive(:platform_auth_set_up_enabled).and_return(true) end - it 'shows signed in user options with webauthn visible', :js do + it 'shows options with webauthn visible', :js, driver: :headless_chrome_mobile do sign_in_user(webauthn_configuration.user) click_link t('two_factor_authentication.login_options_link_text') @@ -570,7 +570,7 @@ def attempt_to_bypass_2fa allow(IdentityConfig.store).to receive(:platform_auth_set_up_enabled).and_return(false) end - it 'shows signed in user options with webauthn visible', :js do + it 'shows options with webauthn visible', :js, driver: :headless_chrome_mobile do sign_in_user(webauthn_configuration.user) click_link t('two_factor_authentication.login_options_link_text') diff --git a/spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb index 0c0037a575f..d5b72d47b4b 100644 --- a/spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb @@ -1,48 +1,62 @@ require 'rails_helper' RSpec.describe TwoFactorAuthentication::WebauthnPlatformSelectionPresenter do - let(:user_without_mfa) { create(:user) } - let(:user_with_mfa) { create(:user) } - let(:configuration) {} - let(:presenter_without_mfa) do - described_class.new(configuration: configuration, user: user_without_mfa) - end - let(:presenter_with_mfa) do - described_class.new(configuration: configuration, user: user_with_mfa) + let(:user) { create(:user) } + subject(:presenter) do + TwoFactorAuthentication::WebauthnPlatformSelectionPresenter.new( + user:, + configuration: user.webauthn_configurations.platform_authenticators.first, + ) end describe '#type' do it 'returns webauthn_platform' do - expect(presenter_without_mfa.type).to eq 'webauthn_platform' + expect(presenter.type).to eq 'webauthn_platform' end end describe '#render_in' do it 'renders a WebauthnInputComponent' do - view_context = ActionController::Base.new.view_context - + view_context = instance_double(ActionView::Base) expect(view_context).to receive(:render) do |component, &block| expect(component).to be_instance_of(WebauthnInputComponent) + expect(component.passkey_supported_only?).to be(true) expect(block.call).to eq('content') end - presenter_without_mfa.render_in(view_context) { 'content' } + presenter.render_in(view_context) { 'content' } + end + + context 'with configured authenticator' do + let(:user) { create(:user, :with_webauthn_platform) } + + it 'renders a WebauthnInputComponent with passkey_supported_only false' do + view_context = instance_double(ActionView::Base) + expect(view_context).to receive(:render) do |component, &block| + expect(component.passkey_supported_only?).to be(false) + end + + presenter.render_in(view_context) + end end end - describe '#mfa_configruation' do + describe '#mfa_configuration' do it 'returns an empty string when user has not configured this authenticator' do - expect(presenter_without_mfa.mfa_configuration_description).to eq('') + expect(presenter.mfa_configuration_description).to eq('') end - it 'returns an # added when user has configured this authenticator' do - create(:webauthn_configuration, platform_authenticator: true, user: user_with_mfa) - expect(presenter_with_mfa.mfa_configuration_description).to eq( - t( - 'two_factor_authentication.two_factor_choice_options.configurations_added', - count: 1, - ), - ) + context 'with configured authenticator' do + let(:user) { create(:user, :with_webauthn_platform) } + + it 'returns an # added when user has configured this authenticator' do + expect(presenter.mfa_configuration_description).to eq( + t( + 'two_factor_authentication.two_factor_choice_options.configurations_added', + count: 1, + ), + ) + end end end end diff --git a/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb index fe473e7b4de..a0c92de0429 100644 --- a/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb @@ -30,7 +30,7 @@ end end - describe '#mfa_configruation' do + describe '#mfa_configuration' do it 'returns an empty string when user has not configured this authenticator' do expect(presenter_without_mfa.mfa_configuration_description).to eq('') end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 783c5c818b5..979dfd5e145 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -20,9 +20,9 @@ Webdrivers.cache_time = 86_400 Capybara.register_driver(:headless_chrome_mobile) do |app| - user_agent_string = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_2 like Mac OS X) ' \ - 'AppleWebKit/537.36 (KHTML, like Gecko) ' \ - 'HeadlessChrome/88.0.4324.150 Safari/537.36' + user_agent_string = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) ' \ + 'AppleWebKit/603.1.23 (KHTML, like Gecko) ' \ + 'HeadlessChrome/88.0.4324.150 Safari/602.1' options = Selenium::WebDriver::Chrome::Options.new options.add_argument('--headless') if !ENV['SHOW_BROWSER']