diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.spec.ts b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.spec.ts new file mode 100644 index 00000000000..9b940e1c3d2 --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.spec.ts @@ -0,0 +1,47 @@ +import { useDefineProperty } from '@18f/identity-test-helpers'; +import isWebauthnPlatformAuthenticatorAvailable from './is-webauthn-platform-authenticator-available'; + +describe('isWebauthnPlatformAuthenticatorAvailable', () => { + const defineProperty = useDefineProperty(); + + context('browser does not support webauthn', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: undefined, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformAuthenticatorAvailable()).to.eventually.equal(false); + }); + }); + + 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(isWebauthnPlatformAuthenticatorAvailable()).to.eventually.equal(false); + }); + }); + + context('device has platform authenticator available', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformAuthenticatorAvailable()).to.eventually.equal(true); + }); + }); + }); +}); diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.ts b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.ts new file mode 100644 index 00000000000..673b59a5473 --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.ts @@ -0,0 +1,6 @@ +export type IsWebauthnPlatformAvailable = () => Promise; + +const isWebauthnPlatformAuthenticatorAvailable: IsWebauthnPlatformAvailable = async () => + !!(await window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable()); + +export default isWebauthnPlatformAuthenticatorAvailable; diff --git a/app/javascript/packages/webauthn/webauthn-input-element.spec.ts b/app/javascript/packages/webauthn/webauthn-input-element.spec.ts index 7c6ee1a0ffb..afd363cc143 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.spec.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.spec.ts @@ -1,6 +1,8 @@ import sinon from 'sinon'; import quibble from 'quibble'; +import { waitFor } from '@testing-library/dom'; import type { IsWebauthnPasskeySupported } from './is-webauthn-passkey-supported'; +import type { IsWebauthnPlatformAvailable } from './is-webauthn-platform-authenticator-available'; describe('WebauthnInputElement', () => { const isWebauthnPasskeySupported = sinon.stub< @@ -8,8 +10,14 @@ describe('WebauthnInputElement', () => { ReturnType >(); + const isWebauthnPlatformAvailable = sinon.stub< + Parameters, + ReturnType + >(); + before(async () => { quibble('./is-webauthn-passkey-supported', isWebauthnPasskeySupported); + quibble('./is-webauthn-platform-authenticator-available', isWebauthnPlatformAvailable); await import('./webauthn-input-element'); }); @@ -21,6 +29,7 @@ describe('WebauthnInputElement', () => { context('unsupported passkey not shown', () => { beforeEach(() => { isWebauthnPasskeySupported.returns(false); + isWebauthnPlatformAvailable.resolves(false); document.body.innerHTML = ``; }); @@ -34,6 +43,7 @@ describe('WebauthnInputElement', () => { context('unsupported passkey shown', () => { beforeEach(() => { isWebauthnPasskeySupported.returns(false); + isWebauthnPlatformAvailable.resolves(false); document.body.innerHTML = ``; }); @@ -47,15 +57,32 @@ describe('WebauthnInputElement', () => { }); context('device supports passkey', () => { - beforeEach(() => { - isWebauthnPasskeySupported.returns(true); - document.body.innerHTML = ``; + context('unsupported publickeycredential not shown', () => { + beforeEach(() => { + isWebauthnPlatformAvailable.resolves(false); + isWebauthnPasskeySupported.returns(true); + document.body.innerHTML = ``; + }); + + it('stays hidden', () => { + const element = document.querySelector('lg-webauthn-input')!; + + expect(element.hidden).to.be.true(); + }); }); - it('becomes visible', () => { - const element = document.querySelector('lg-webauthn-input')!; + context('publickeycredential input is shown', () => { + beforeEach(() => { + isWebauthnPasskeySupported.returns(true); + isWebauthnPlatformAvailable.resolves(true); + document.body.innerHTML = ``; + }); + + 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()); + }); }); }); }); diff --git a/app/javascript/packages/webauthn/webauthn-input-element.ts b/app/javascript/packages/webauthn/webauthn-input-element.ts index 11938ab51a6..954cac625c0 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.ts @@ -1,4 +1,5 @@ import isWebauthnPasskeySupported from './is-webauthn-passkey-supported'; +import isWebauthnPlatformAuthenticatorAvailable from './is-webauthn-platform-authenticator-available'; export class WebauthnInputElement extends HTMLElement { connectedCallback() { @@ -13,12 +14,12 @@ export class WebauthnInputElement extends HTMLElement { return this.hasAttribute('show-unsupported-passkey'); } - toggleVisibleIfPasskeySupported() { + async toggleVisibleIfPasskeySupported() { if (!this.hasAttribute('hidden')) { return; } - if (isWebauthnPasskeySupported()) { + if (isWebauthnPasskeySupported() && (await isWebauthnPlatformAuthenticatorAvailable())) { this.hidden = false; } else if (this.showUnsupportedPasskey) { this.hidden = false; diff --git a/spec/features/webauthn/hidden_spec.rb b/spec/features/webauthn/hidden_spec.rb index 28ba155b4bc..7567ee11224 100644 --- a/spec/features/webauthn/hidden_spec.rb +++ b/spec/features/webauthn/hidden_spec.rb @@ -2,6 +2,7 @@ RSpec.describe 'webauthn hide' do include JavascriptDriverHelper + include WebAuthnHelper describe 'security key' do let(:option_id) { 'two_factor_options_form_selection_webauthn' } @@ -58,9 +59,11 @@ expect(webauthn_option_hidden?).to eq(true) end - context 'with supported browser', driver: :headless_chrome_mobile do + context 'with supported browser and platform authenticator available', + driver: :headless_chrome_mobile do it 'displays the authenticator option' do sign_up_and_set_password + simulate_platform_authenticator_available expect(webauthn_option_hidden?).to eq(false) end diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb index 963f6b2dda3..2800a436c60 100644 --- a/spec/support/features/webauthn_helper.rb +++ b/spec/support/features/webauthn_helper.rb @@ -127,6 +127,15 @@ def set_hidden_field(id, value) end end + def simulate_platform_authenticator_available + page.evaluate_script(<<~JS) + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () => Promise.resolve(true); + JS + page.evaluate_script(<<~JS) + document.querySelectorAll('lg-webauthn-input').forEach((input) => input.connectedCallback()); + JS + end + def protocol 'http://' end