diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-19 01:45:44 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-19 01:45:44 +0000 |
commit | 85dc423f7090da0a52c73eb66faf22ddb20efff9 (patch) | |
tree | 9160f299afd8c80c038f08e1545be119f5e3f1e1 /spec/frontend/authentication/webauthn | |
parent | 15c2c8c66dbe422588e5411eee7e68f1fa440bb8 (diff) | |
download | gitlab-ce-85dc423f7090da0a52c73eb66faf22ddb20efff9.tar.gz |
Add latest changes from gitlab-org/gitlab@13-4-stable-ee
Diffstat (limited to 'spec/frontend/authentication/webauthn')
5 files changed, 367 insertions, 0 deletions
diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js new file mode 100644 index 00000000000..0a82adfd0ee --- /dev/null +++ b/spec/frontend/authentication/webauthn/authenticate_spec.js @@ -0,0 +1,132 @@ +import $ from 'jquery'; +import waitForPromises from 'helpers/wait_for_promises'; +import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate'; +import MockWebAuthnDevice from './mock_webauthn_device'; +import { useMockNavigatorCredentials } from './util'; + +const mockResponse = { + type: 'public-key', + id: '', + rawId: '', + response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' }, + getClientExtensionResults: () => {}, +}; + +describe('WebAuthnAuthenticate', () => { + preloadFixtures('webauthn/authenticate.html'); + useMockNavigatorCredentials(); + + let fallbackElement; + let webAuthnDevice; + let container; + let component; + let submitSpy; + + const findDeviceResponseInput = () => container[0].querySelector('#js-device-response'); + const findDeviceResponseInputValue = () => findDeviceResponseInput().value; + const findMessage = () => container[0].querySelector('p'); + const findRetryButton = () => container[0].querySelector('#js-token-2fa-try-again'); + const expectAuthenticated = () => { + expect(container.text()).toMatchInterpolatedText( + 'We heard back from your device. You have been authenticated.', + ); + expect(findDeviceResponseInputValue()).toBe(JSON.stringify(mockResponse)); + expect(submitSpy).toHaveBeenCalled(); + }; + + beforeEach(() => { + loadFixtures('webauthn/authenticate.html'); + fallbackElement = document.createElement('div'); + fallbackElement.classList.add('js-2fa-form'); + webAuthnDevice = new MockWebAuthnDevice(); + container = $('#js-authenticate-token-2fa'); + component = new WebAuthnAuthenticate( + container, + '#js-login-token-2fa-form', + { + options: + // we need some valid base64 for base64ToBuffer + // so we use "YQ==" = base64("a") + JSON.stringify({ + challenge: 'YQ==', + timeout: 120000, + allowCredentials: [ + { type: 'public-key', id: 'YQ==' }, + { type: 'public-key', id: 'YQ==' }, + ], + userVerification: 'discouraged', + }), + }, + document.querySelector('#js-login-2fa-device'), + fallbackElement, + ); + submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit'); + }); + + describe('with webauthn unavailable', () => { + let oldGetCredentials; + + beforeEach(() => { + oldGetCredentials = window.navigator.credentials.get; + window.navigator.credentials.get = null; + }); + + afterEach(() => { + window.navigator.credentials.get = oldGetCredentials; + }); + + it('falls back to normal 2fa', () => { + component.start(); + + expect(container.html()).toBe(''); + expect(container[0]).toHaveClass('hidden'); + expect(fallbackElement).not.toHaveClass('hidden'); + }); + }); + + describe('with webauthn available', () => { + beforeEach(() => { + component.start(); + }); + + it('shows in progress', () => { + const inProgressMessage = container.find('p'); + + expect(inProgressMessage.text()).toMatchInterpolatedText( + "Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.", + ); + }); + + it('allows authenticating via a WebAuthn device', () => { + webAuthnDevice.respondToAuthenticateRequest(mockResponse); + + return waitForPromises().then(() => { + expectAuthenticated(); + }); + }); + + describe('errors', () => { + beforeEach(() => { + webAuthnDevice.rejectAuthenticateRequest(new DOMException()); + + return waitForPromises(); + }); + + it('displays an error message', () => { + expect(submitSpy).not.toHaveBeenCalled(); + expect(findMessage().textContent).toMatchInterpolatedText( + 'There was a problem communicating with your device. (Error)', + ); + }); + + it('allows retrying authentication after an error', () => { + findRetryButton().click(); + webAuthnDevice.respondToAuthenticateRequest(mockResponse); + + return waitForPromises().then(() => { + expectAuthenticated(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js new file mode 100644 index 00000000000..26f1ca5e27d --- /dev/null +++ b/spec/frontend/authentication/webauthn/error_spec.js @@ -0,0 +1,50 @@ +import WebAuthnError from '~/authentication/webauthn/error'; + +describe('WebAuthnError', () => { + it.each([ + [ + 'NotSupportedError', + 'Your device is not compatible with GitLab. Please try another device', + 'authenticate', + ], + ['InvalidStateError', 'This device has not been registered with us.', 'authenticate'], + ['InvalidStateError', 'This device has already been registered with us.', 'register'], + ['UnknownError', 'There was a problem communicating with your device.', 'register'], + ])('exception %s will have message %s, flow type: %s', (exception, expectedMessage, flowType) => { + expect(new WebAuthnError(new DOMException('', exception), flowType).message()).toEqual( + expectedMessage, + ); + }); + + describe('SecurityError', () => { + const { location } = window; + + beforeEach(() => { + delete window.location; + window.location = {}; + }); + + afterEach(() => { + window.location = location; + }); + + it('returns a descriptive error if https is disabled', () => { + window.location.protocol = 'http:'; + + const expectedMessage = + 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.'; + expect( + new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(), + ).toEqual(expectedMessage); + }); + + it('returns a generic error if https is enabled', () => { + window.location.protocol = 'https:'; + + const expectedMessage = 'There was a problem communicating with your device.'; + expect( + new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(), + ).toEqual(expectedMessage); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/mock_webauthn_device.js b/spec/frontend/authentication/webauthn/mock_webauthn_device.js new file mode 100644 index 00000000000..39df94df46b --- /dev/null +++ b/spec/frontend/authentication/webauthn/mock_webauthn_device.js @@ -0,0 +1,35 @@ +/* eslint-disable no-unused-expressions */ + +export default class MockWebAuthnDevice { + constructor() { + this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); + this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); + window.navigator.credentials || (window.navigator.credentials = {}); + window.navigator.credentials.create = () => + new Promise((resolve, reject) => { + this.registerCallback = resolve; + this.registerRejectCallback = reject; + }); + window.navigator.credentials.get = () => + new Promise((resolve, reject) => { + this.authenticateCallback = resolve; + this.authenticateRejectCallback = reject; + }); + } + + respondToRegisterRequest(params) { + return this.registerCallback(params); + } + + respondToAuthenticateRequest(params) { + return this.authenticateCallback(params); + } + + rejectRegisterRequest(params) { + return this.registerRejectCallback(params); + } + + rejectAuthenticateRequest(params) { + return this.authenticateRejectCallback(params); + } +} diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js new file mode 100644 index 00000000000..1de952d176d --- /dev/null +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -0,0 +1,131 @@ +import $ from 'jquery'; +import waitForPromises from 'helpers/wait_for_promises'; +import WebAuthnRegister from '~/authentication/webauthn/register'; +import MockWebAuthnDevice from './mock_webauthn_device'; +import { useMockNavigatorCredentials } from './util'; + +describe('WebAuthnRegister', () => { + preloadFixtures('webauthn/register.html'); + useMockNavigatorCredentials(); + + const mockResponse = { + type: 'public-key', + id: '', + rawId: '', + response: { + clientDataJSON: '', + attestationObject: '', + }, + getClientExtensionResults: () => {}, + }; + let webAuthnDevice; + let container; + let component; + + beforeEach(() => { + loadFixtures('webauthn/register.html'); + webAuthnDevice = new MockWebAuthnDevice(); + container = $('#js-register-token-2fa'); + component = new WebAuthnRegister(container, { + options: { + rp: '', + user: { + id: '', + name: '', + displayName: '', + }, + challenge: '', + pubKeyCredParams: '', + }, + }); + component.start(); + }); + + const findSetupButton = () => container.find('#js-setup-token-2fa-device'); + const findMessage = () => container.find('p'); + const findDeviceResponse = () => container.find('#js-device-response'); + const findRetryButton = () => container.find('#js-token-2fa-try-again'); + + it('shows setup button', () => { + expect(findSetupButton().text()).toBe('Set up new device'); + }); + + describe('when unsupported', () => { + const { location, PublicKeyCredential } = window; + + beforeEach(() => { + delete window.location; + delete window.credentials; + window.location = {}; + window.PublicKeyCredential = undefined; + }); + + afterEach(() => { + window.location = location; + window.PublicKeyCredential = PublicKeyCredential; + }); + + it.each` + httpsEnabled | expectedText + ${false} | ${'WebAuthn only works with HTTPS-enabled websites'} + ${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'} + `('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => { + window.location.protocol = httpsEnabled ? 'https:' : 'http:'; + component.start(); + + expect(findMessage().text()).toContain(expectedText); + }); + }); + + describe('when setup', () => { + beforeEach(() => { + findSetupButton().trigger('click'); + }); + + it('shows in progress message', () => { + expect(findMessage().text()).toContain('Trying to communicate with your device'); + }); + + it('registers device', () => { + webAuthnDevice.respondToRegisterRequest(mockResponse); + + return waitForPromises().then(() => { + expect(findMessage().text()).toContain('Your device was successfully set up!'); + expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse)); + }); + }); + + it.each` + errorName | expectedText + ${'NotSupportedError'} | ${'Your device is not compatible with GitLab'} + ${'NotAllowedError'} | ${'There was a problem communicating with your device'} + `('when fails with $errorName', ({ errorName, expectedText }) => { + webAuthnDevice.rejectRegisterRequest(new DOMException('', errorName)); + + return waitForPromises().then(() => { + expect(findMessage().text()).toContain(expectedText); + expect(findRetryButton().length).toBe(1); + }); + }); + + it('can retry', () => { + webAuthnDevice.respondToRegisterRequest({ + errorCode: 'error!', + }); + + return waitForPromises() + .then(() => { + findRetryButton().click(); + + expect(findMessage().text()).toContain('Trying to communicate with your device'); + + webAuthnDevice.respondToRegisterRequest(mockResponse); + return waitForPromises(); + }) + .then(() => { + expect(findMessage().text()).toContain('Your device was successfully set up!'); + expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse)); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/util.js b/spec/frontend/authentication/webauthn/util.js new file mode 100644 index 00000000000..d8f5a67ee1f --- /dev/null +++ b/spec/frontend/authentication/webauthn/util.js @@ -0,0 +1,19 @@ +export function useMockNavigatorCredentials() { + let oldNavigatorCredentials; + let oldPublicKeyCredential; + + beforeEach(() => { + oldNavigatorCredentials = navigator.credentials; + oldPublicKeyCredential = window.PublicKeyCredential; + navigator.credentials = { + get: jest.fn(), + create: jest.fn(), + }; + window.PublicKeyCredential = function MockPublicKeyCredential() {}; + }); + + afterEach(() => { + navigator.credentials = oldNavigatorCredentials; + window.PublicKeyCredential = oldPublicKeyCredential; + }); +} |