From b96b2c360ed873614759dca3dd8576454279b4bc Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 16 Jun 2023 14:35:03 -0400 Subject: [PATCH 1/9] LG-9885: Restrict platform authenticator setup based on device support changelog: Upcoming Features, Face or Touch Unlock, Restrict availability of authentication method based on device support --- .../is-webauthn-platform-supported.spec.ts | 132 ++++++++++++++++-- .../is-webauthn-platform-supported.ts | 25 ++++ 2 files changed, 143 insertions(+), 14 deletions(-) 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..7f2248bd72e 100644 --- a/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts +++ b/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts @@ -1,28 +1,117 @@ import { useDefineProperty } from '@18f/identity-test-helpers'; import isWebauthnPlatformSupported from './is-webauthn-platform-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_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://chromium.googlesource.com/chromium/src/+/master/docs/ios/user_agent.md +const UNSUPPORTED_IOS_CHROME_VERSION_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_SAFARI_VERSION_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_UA, + SUPPORTED_IOS_CHROME_VERSION_UA, + SUPPORTED_IOS_SAFARI_VERSION_UA, +]; + +const UNSUPPORTED_USER_AGENTS = [ + UNSUPPORTED_ANDROID_VERSION_UA, + UNSUPPORTED_IOS_CHROME_VERSION_UA, + UNSUPPORTED_IOS_SAFARI_VERSION_UA, + FIREFOX_UA, + DESKTOP_CHROME_UA, + DESKTOP_SAFARI_UA, +]; + describe('isWebauthnPlatformSupported', () => { const defineProperty = useDefineProperty(); - context('browser does not support webauthn', () => { + describe('user agent support', () => { beforeEach(() => { defineProperty(window, 'PublicKeyCredential', { configurable: true, - value: undefined, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, + }); + }); + + UNSUPPORTED_USER_AGENTS.forEach((userAgent) => { + context(userAgent, () => { + beforeEach(() => { + defineProperty(navigator, 'userAgent', { + configurable: true, + value: userAgent, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformSupported()).to.eventually.equal(false); + }); }); }); - it('resolves to false', async () => { - await expect(isWebauthnPlatformSupported()).to.eventually.equal(false); + SUPPORTED_USER_AGENTS.forEach((userAgent) => { + context(userAgent, () => { + beforeEach(() => { + defineProperty(navigator, 'userAgent', { + configurable: true, + value: userAgent, + }); + }); + + it('resolves to true', async () => { + await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); + }); + }); }); }); - context('browser supports webauthn', () => { - context('device does not have platform authenticator available', () => { + context('supported user agent', () => { + beforeEach(() => { + defineProperty(navigator, 'userAgent', { + configurable: true, + value: SUPPORTED_ANDROID_VERSION_UA, + }); + }); + + context('browser does not support webauthn', () => { beforeEach(() => { defineProperty(window, 'PublicKeyCredential', { configurable: true, - value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(false) }, + value: undefined, }); }); @@ -31,16 +120,31 @@ describe('isWebauthnPlatformSupported', () => { }); }); - context('device has platform authenticator available', () => { - beforeEach(() => { - defineProperty(window, 'PublicKeyCredential', { - configurable: true, - value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, + context('browser supports webauthn', () => { + context('device does not have platform authenticator available', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(false) }, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformSupported()).to.eventually.equal(false); }); }); - it('resolves to false', async () => { - await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); + context('device has platform authenticator available', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, + }); + }); + + it('resolves to true', async () => { + await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); + }); }); }); }); diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts b/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts index fa52218e416..f18744e0b09 100644 --- a/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts +++ b/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts @@ -1,6 +1,31 @@ export type IsWebauthnPlatformSupported = () => Promise; +const MINIMUM_IOS_VERSION = 16; + +const MINIMUM_ANDROID_VERSION = 9; + +function isQualifyingIOSDevice(): boolean { + const match = navigator.userAgent.match(/iPhone; CPU iPhone OS (\d+)_|iPad; CPU 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 isWebauthnPlatformSupported: IsWebauthnPlatformSupported = async () => + (isQualifyingIOSDevice() || isQualifyingAndroidDevice()) && !!(await window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable()); export default isWebauthnPlatformSupported; From b053288f8966580043500c4ceb38b5e120f55532 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 16 Jun 2023 14:52:30 -0400 Subject: [PATCH 2/9] Add coverage for Android Firefox exclusion --- .../is-webauthn-platform-supported.spec.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 7f2248bd72e..791070ef16d 100644 --- a/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts +++ b/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts @@ -10,15 +10,19 @@ 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_UA = +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_CHROME_VERSION_UA = +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_SAFARI_VERSION_UA = +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 @@ -43,15 +47,16 @@ const DESKTOP_SAFARI_UA = const SUPPORTED_USER_AGENTS = [ REDUCED_UNSUPPORTED_ANDROID_VERSION_UA, - SUPPORTED_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, - UNSUPPORTED_IOS_CHROME_VERSION_UA, - UNSUPPORTED_IOS_SAFARI_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, @@ -103,7 +108,7 @@ describe('isWebauthnPlatformSupported', () => { beforeEach(() => { defineProperty(navigator, 'userAgent', { configurable: true, - value: SUPPORTED_ANDROID_VERSION_UA, + value: SUPPORTED_ANDROID_VERSION_CHROME_UA, }); }); From 658413082094be8b31eeb93eee970cf399fb4c8a Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 16 Jun 2023 14:58:26 -0400 Subject: [PATCH 3/9] Fix feature specs for WebAuthn --- spec/features/two_factor_authentication/sign_in_spec.rb | 4 ++-- spec/support/capybara.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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'] From 15cd5ffa26badbf2ca32539a2b680871cd2c8697 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 16 Jun 2023 15:21:41 -0400 Subject: [PATCH 4/9] Filter by passkey support for initial setup --- app/components/webauthn_input_component.rb | 7 +- .../is-webauthn-passkey-supported.spec.ts | 104 +++++++++++++ .../webauthn/is-webauthn-passkey-supported.ts | 30 ++++ .../is-webauthn-platform-supported.spec.ts | 137 ++---------------- .../is-webauthn-platform-supported.ts | 25 ---- .../webauthn/webauth-input-element.spec.ts | 57 ++++++-- .../webauthn/webauthn-input-element.ts | 27 +++- .../webauthn_platform_selection_presenter.rb | 5 +- .../webauthn_input_component_spec.rb | 28 ++++ 9 files changed, 256 insertions(+), 164 deletions(-) create mode 100644 app/javascript/packages/webauthn/is-webauthn-passkey-supported.spec.ts create mode 100644 app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts 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..e62267e470b --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts @@ -0,0 +1,30 @@ +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+)_|iPad; CPU 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 791070ef16d..5e1957f6c5c 100644 --- a/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts +++ b/app/javascript/packages/webauthn/is-webauthn-platform-supported.spec.ts @@ -1,122 +1,28 @@ import { useDefineProperty } from '@18f/identity-test-helpers'; import isWebauthnPlatformSupported from './is-webauthn-platform-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('isWebauthnPlatformSupported', () => { const defineProperty = useDefineProperty(); - describe('user agent support', () => { + context('browser does not support webauthn', () => { 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', async () => { - await expect(isWebauthnPlatformSupported()).to.eventually.equal(false); - }); + value: undefined, }); }); - SUPPORTED_USER_AGENTS.forEach((userAgent) => { - context(userAgent, () => { - beforeEach(() => { - defineProperty(navigator, 'userAgent', { - configurable: true, - value: userAgent, - }); - }); - - it('resolves to true', async () => { - await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); - }); - }); + it('resolves to false', async () => { + await expect(isWebauthnPlatformSupported()).to.eventually.equal(false); }); }); - context('supported user agent', () => { - beforeEach(() => { - defineProperty(navigator, 'userAgent', { - configurable: true, - value: SUPPORTED_ANDROID_VERSION_CHROME_UA, - }); - }); - - context('browser does not support webauthn', () => { + context('browser supports webauthn', () => { + context('device does not have platform authenticator available', () => { beforeEach(() => { defineProperty(window, 'PublicKeyCredential', { configurable: true, - value: undefined, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(false) }, }); }); @@ -125,31 +31,16 @@ describe('isWebauthnPlatformSupported', () => { }); }); - context('browser supports webauthn', () => { - context('device does not have platform authenticator available', () => { - beforeEach(() => { - defineProperty(window, 'PublicKeyCredential', { - configurable: true, - value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(false) }, - }); - }); - - it('resolves to false', async () => { - await expect(isWebauthnPlatformSupported()).to.eventually.equal(false); + context('device has platform authenticator available', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, }); }); - context('device has platform authenticator available', () => { - beforeEach(() => { - defineProperty(window, 'PublicKeyCredential', { - configurable: true, - value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, - }); - }); - - it('resolves to true', async () => { - await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); - }); + it('resolves to true', async () => { + await expect(isWebauthnPlatformSupported()).to.eventually.equal(true); }); }); }); diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts b/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts index f18744e0b09..fa52218e416 100644 --- a/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts +++ b/app/javascript/packages/webauthn/is-webauthn-platform-supported.ts @@ -1,31 +1,6 @@ export type IsWebauthnPlatformSupported = () => Promise; -const MINIMUM_IOS_VERSION = 16; - -const MINIMUM_ANDROID_VERSION = 9; - -function isQualifyingIOSDevice(): boolean { - const match = navigator.userAgent.match(/iPhone; CPU iPhone OS (\d+)_|iPad; CPU 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 isWebauthnPlatformSupported: IsWebauthnPlatformSupported = async () => - (isQualifyingIOSDevice() || isQualifyingAndroidDevice()) && !!(await window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable()); export default isWebauthnPlatformSupported; 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..f030af7aa9a 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.ts @@ -1,7 +1,10 @@ +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 { + isInitialized = false; + connectedCallback() { this.toggleVisibleIfSupported(); } @@ -10,10 +13,32 @@ export class WebauthnInputElement extends HTMLElement { 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'); } + + this.isInitialized = true; } } 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' }) } From 5580deb3873f6d336899f52c3b3ab04c655abb41 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 20 Jun 2023 07:41:22 -0400 Subject: [PATCH 5/9] Remove iPad checks See: https://github.com/18F/identity-idp/pull/8615#discussion_r1235141940 --- .../packages/webauthn/is-webauthn-passkey-supported.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts index e62267e470b..60f15e35f1b 100644 --- a/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts +++ b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts @@ -5,7 +5,7 @@ const MINIMUM_IOS_VERSION = 16; const MINIMUM_ANDROID_VERSION = 9; function isQualifyingIOSDevice(): boolean { - const match = navigator.userAgent.match(/iPhone; CPU iPhone OS (\d+)_|iPad; CPU OS (\d+)_/); + const match = navigator.userAgent.match(/iPhone; CPU iPhone OS (\d+)_/); const iOSVersion: null | number = match && Number(match[1]); return !!iOSVersion && iOSVersion >= MINIMUM_IOS_VERSION; } From 6eab93304e22315c4e35d4ee4725d17a76043941 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 20 Jun 2023 07:42:09 -0400 Subject: [PATCH 6/9] Fix lint error --- .../packages/webauthn/is-webauthn-passkey-supported.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts index 60f15e35f1b..b496028322b 100644 --- a/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts +++ b/app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts @@ -1,4 +1,4 @@ -type IsWebauthnPasskeySupported = () => boolean; +export type IsWebauthnPasskeySupported = () => boolean; const MINIMUM_IOS_VERSION = 16; From 12a3966bbaef7d4c8374b9f48c2869f678ffa55b Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 20 Jun 2023 09:13:05 -0400 Subject: [PATCH 7/9] Add spec coverage for passkey_supported_only See: https://github.com/18F/identity-idp/pull/8615#discussion_r1232741892 --- ...authn_platform_selection_presenter_spec.rb | 19 ++++++++++++++++++- .../webauthn_selection_presenter_spec.rb | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) 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..1a4f3d079ef 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 @@ -23,14 +23,31 @@ 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' } end + + context 'with configured authenticator' do + let(:configuration) do + create(:webauthn_configuration, platform_authenticator: true, user: user_with_mfa) + end + + it 'renders a WebauthnInputComponent with passkey_supported_only false' do + view_context = ActionController::Base.new.view_context + + expect(view_context).to receive(:render) do |component, &block| + expect(component.passkey_supported_only?).to be(false) + end + + presenter_without_mfa.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('') 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 From f5a51b1261585f1ae0960420346328d75a91e1d7 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 20 Jun 2023 09:14:24 -0400 Subject: [PATCH 8/9] Move initialized setter to connectedCallback --- app/javascript/packages/webauthn/webauthn-input-element.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/javascript/packages/webauthn/webauthn-input-element.ts b/app/javascript/packages/webauthn/webauthn-input-element.ts index f030af7aa9a..5d7e5327d09 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.ts @@ -5,8 +5,9 @@ import isWebauthnSupported from './is-webauthn-supported'; export class WebauthnInputElement extends HTMLElement { isInitialized = false; - connectedCallback() { - this.toggleVisibleIfSupported(); + async connectedCallback() { + await this.toggleVisibleIfSupported(); + this.isInitialized = true; } get isPlatform(): boolean { @@ -37,8 +38,6 @@ export class WebauthnInputElement extends HTMLElement { if (await this.isSupported()) { this.removeAttribute('hidden'); } - - this.isInitialized = true; } } From 444e9a2ef3f501520b1a475d52e8f31a7d111862 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 20 Jun 2023 14:14:54 -0400 Subject: [PATCH 9/9] Try to fix specs Maybe there's some strange ordering/context. This should be simpler anyways --- Gemfile.lock | 6 +-- ...authn_platform_selection_presenter_spec.rb | 51 +++++++++---------- 2 files changed, 27 insertions(+), 30 deletions(-) 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/spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb index 1a4f3d079ef..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,65 +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(:configuration) do - create(:webauthn_configuration, platform_authenticator: true, user: user_with_mfa) - end + let(:user) { create(:user, :with_webauthn_platform) } it 'renders a WebauthnInputComponent with passkey_supported_only false' 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.passkey_supported_only?).to be(false) end - presenter_without_mfa.render_in(view_context) + presenter.render_in(view_context) end end end 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