Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions app/components/webauthn_input_component.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -15,6 +17,7 @@ def call
**tag_options,
hidden: true,
platform: platform.presence,
'passkey-supported-only': passkey_supported_only.presence,
)
end
end
104 changes: 104 additions & 0 deletions app/javascript/packages/webauthn/is-webauthn-passkey-supported.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
30 changes: 30 additions & 0 deletions app/javascript/packages/webauthn/is-webauthn-passkey-supported.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('isWebauthnPlatformSupported', () => {
});
});

it('resolves to false', async () => {
it('resolves to true', async () => {
await expect(isWebauthnPlatformSupported()).to.eventually.equal(true);
});
});
Expand Down
57 changes: 45 additions & 12 deletions app/javascript/packages/webauthn/webauth-input-element.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,35 @@ 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', () => {
const isWebauthnSupported = sinon.stub<
Parameters<IsWebauthnSupported>,
ReturnType<IsWebauthnSupported>
>();
const isWebauthnPasskeySupported = sinon.stub<
Parameters<IsWebauthnPasskeySupported>,
ReturnType<IsWebauthnPasskeySupported>
>();
const isWebauthnPlatformSupported = sinon.stub<
Parameters<IsWebauthnPlatformSupported>,
ReturnType<IsWebauthnPlatformSupported>
>();

before(async () => {
quibble('./is-webauthn-supported', isWebauthnSupported);
quibble('./is-webauthn-passkey-supported', isWebauthnPasskeySupported);
quibble('./is-webauthn-platform-supported', isWebauthnPlatformSupported);
await import('./webauthn-input-element');
});

beforeEach(() => {
isWebauthnSupported.reset();
isWebauthnSupported.returns(false);
isWebauthnPasskeySupported.reset();
isWebauthnPasskeySupported.resolves(false);
isWebauthnPlatformSupported.reset();
isWebauthnPlatformSupported.resolves(false);
});
Expand Down Expand Up @@ -54,32 +62,25 @@ describe('WebauthnInputElement', () => {
document.body.innerHTML = `<lg-webauthn-input hidden></lg-webauthn-input>`;
});

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 = `<lg-webauthn-input platform hidden></lg-webauthn-input>`;
});

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();
});
});
Expand All @@ -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 = `<lg-webauthn-input platform passkey-supported-only hidden></lg-webauthn-input>`;
});

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 = `<lg-webauthn-input platform passkey-supported-only hidden></lg-webauthn-input>`;
});

it('becomes visible', async () => {
const element = document.querySelector('lg-webauthn-input')!;

await waitFor(() => expect(element.hidden).to.be.false());
});
});
});
});
});
});
30 changes: 27 additions & 3 deletions app/javascript/packages/webauthn/webauthn-input-element.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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');
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
aduth marked this conversation as resolved.
Outdated
)
end

def disabled?
Expand Down
28 changes: 28 additions & 0 deletions spec/components/webauthn_input_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand All @@ -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' }) }

Expand Down
Loading