diff --git a/packages/browser/src/methods/startAuthentication.test.ts b/packages/browser/src/methods/startAuthentication.test.ts index 46aac1c9..16e48c80 100644 --- a/packages/browser/src/methods/startAuthentication.test.ts +++ b/packages/browser/src/methods/startAuthentication.test.ts @@ -1,3 +1,15 @@ +/// +import { + assert, + assertEquals, + assertExists, + assertInstanceOf, + assertRejects, + assertStringIncludes, +} from '@std/assert'; +import { assertSpyCalls, type Spy, spy, stub } from '@std/testing/mock'; +import { afterEach, beforeEach, describe, it } from '@std/testing/bdd'; +import { JSDOM } from 'jsdom'; import { AuthenticationCredential, AuthenticationExtensionsClientInputs, @@ -5,8 +17,8 @@ import { PublicKeyCredentialRequestOptionsJSON, } from '@simplewebauthn/types'; -import { browserSupportsWebAuthn } from '../helpers/browserSupportsWebAuthn.ts'; -import { browserSupportsWebAuthnAutofill } from '../helpers/browserSupportsWebAuthnAutofill.ts'; +import { _browserSupportsWebAuthnInternals } from '../helpers/browserSupportsWebAuthn.ts'; +import { _browserSupportsWebAuthnAutofillInternals } from '../helpers/browserSupportsWebAuthnAutofill.ts'; import { base64URLStringToBuffer } from '../helpers/base64URLStringToBuffer.ts'; import { bufferToBase64URLString } from '../helpers/bufferToBase64URLString.ts'; import { WebAuthnError } from '../helpers/webAuthnError.ts'; @@ -15,20 +27,9 @@ import { WebAuthnAbortService } from '../helpers/webAuthnAbortService.ts'; import { startAuthentication } from './startAuthentication.ts'; -jest.mock('../helpers/browserSupportsWebAuthn'); -jest.mock('../helpers/browserSupportsWebAuthnAutofill'); - -const mockNavigatorGet = window.navigator.credentials.get as jest.Mock; -const mockSupportsWebAuthn = browserSupportsWebAuthn as jest.Mock; -const mockSupportsAutofill = browserSupportsWebAuthnAutofill as jest.Mock; - -const mockAuthenticatorData = 'mockAuthenticatorData'; -const mockClientDataJSON = 'mockClientDataJSON'; -const mockSignature = 'mockSignature'; -const mockUserHandle = 'f4pdy3fpA34'; - // With ASCII challenge const goodOpts1: PublicKeyCredentialRequestOptionsJSON = { + rpId: 'example.com', challenge: '1T6uHri4OAQ', allowCredentials: [ { @@ -47,392 +48,429 @@ const goodOpts2UTF8: PublicKeyCredentialRequestOptionsJSON = { timeout: 1, }; -beforeEach(() => { - // Stub out a response so the method won't throw - mockNavigatorGet.mockImplementation((): Promise => { - return new Promise((resolve) => { - resolve({ - response: {}, - getClientExtensionResults: () => ({}), - }); - }); - }); +const defaultGetResponse = async (...args: any[]) => ({ + response: {}, + getClientExtensionResults: () => ({}), +}); - mockSupportsWebAuthn.mockReturnValue(true); - mockSupportsAutofill.mockResolvedValue(true); +describe('Method: startAuthentication()', () => { + let getSpy: Spy; - // Reset the abort service so we get an accurate call count - WebAuthnAbortService.cancelCeremony(); -}); + beforeEach(() => { + // Stub out a response so the method won't throw + getSpy = spy(defaultGetResponse); + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials = { get: getSpy }; -afterEach(() => { - mockNavigatorGet.mockReset(); - mockSupportsWebAuthn.mockReset(); - mockSupportsAutofill.mockReset(); -}); + // Assume WebAuthn is available + _browserSupportsWebAuthnInternals.stubThis = () => true; + // Assume conditional UI is supported + _browserSupportsWebAuthnAutofillInternals.stubThis = async () => true; + }); -test('should convert options before passing to navigator.credentials.get(...)', async () => { - await startAuthentication({ optionsJSON: goodOpts1 }); + afterEach(() => { + // Reset the abort service so we get an accurate call count + WebAuthnAbortService.cancelCeremony(); + }); - const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; - const credId = argsPublicKey.allowCredentials[0].id; + it('should convert options before passing to navigator.credentials.get(...)', async () => { + await startAuthentication({ optionsJSON: goodOpts1 }); - expect(new Uint8Array(argsPublicKey.challenge)).toEqual( - new Uint8Array([213, 62, 174, 30, 184, 184, 56, 4]), - ); - // Make sure the credential ID is an ArrayBuffer with a length of 64 - expect(credId instanceof ArrayBuffer).toEqual(true); - expect(credId.byteLength).toEqual(64); -}); + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + const credId = argsPublicKey.allowCredentials?.[0].id; -test('should support optional allowCredential', async () => { - await startAuthentication({ - optionsJSON: { - challenge: '1T6uHri4OAQ', - timeout: 1, - }, + assertEquals( + new Uint8Array(argsPublicKey.challenge as ArrayBuffer), + new Uint8Array([213, 62, 174, 30, 184, 184, 56, 4]), + ); + // Make sure the credential ID is an ArrayBuffer with a length of 64 + assertInstanceOf(credId, ArrayBuffer); + assertEquals(credId.byteLength, 64); }); - expect(mockNavigatorGet.mock.calls[0][0].allowCredentials).toEqual(undefined); -}); + it('should support optional allowCredential', async () => { + await startAuthentication({ + optionsJSON: { + challenge: '1T6uHri4OAQ', + timeout: 1, + }, + }); -test('should convert allow allowCredential to undefined when empty', async () => { - await startAuthentication({ - optionsJSON: { - challenge: '1T6uHri4OAQ', - timeout: 1, - allowCredentials: [], - }, + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + + assertEquals(argsPublicKey.allowCredentials, undefined); }); - expect(mockNavigatorGet.mock.calls[0][0].allowCredentials).toEqual(undefined); -}); -test('should return base64url-encoded response values', async () => { - mockNavigatorGet.mockImplementation((): Promise => { - return new Promise((resolve) => { - resolve({ - id: 'foobar', - rawId: Buffer.from('foobar', 'ascii'), - response: { - authenticatorData: Buffer.from(mockAuthenticatorData, 'ascii'), - clientDataJSON: Buffer.from(mockClientDataJSON, 'ascii'), - signature: Buffer.from(mockSignature, 'ascii'), - userHandle: base64URLStringToBuffer(mockUserHandle), - }, - getClientExtensionResults: () => ({}), - type: 'public-key', - authenticatorAttachment: '', - }); + it('should convert allow allowCredential to undefined when empty', async () => { + await startAuthentication({ + optionsJSON: { + challenge: '1T6uHri4OAQ', + timeout: 1, + allowCredentials: [], + }, }); + + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + + assertEquals(argsPublicKey.allowCredentials, undefined); }); - const response = await startAuthentication({ optionsJSON: goodOpts1 }); + it('should return base64url-encoded response values', async () => { + globalThis.navigator.credentials.get = async () => ({ + id: 'foobar', + rawId: new Uint8Array([0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72]), + response: { + authenticatorData: new Uint8Array([1, 2, 3, 4]), + clientDataJSON: new Uint8Array([5, 6, 7, 8]), + signature: new Uint8Array([9, 0, 1, 2]), + userHandle: new Uint8Array([3, 4, 5, 6]), + }, + getClientExtensionResults: () => ({}), + type: 'public-key', + authenticatorAttachment: '', + }); - expect(response.rawId).toEqual('Zm9vYmFy'); - expect(response.response.authenticatorData).toEqual( - 'bW9ja0F1dGhlbnRpY2F0b3JEYXRh', - ); - expect(response.response.clientDataJSON).toEqual('bW9ja0NsaWVudERhdGFKU09O'); - expect(response.response.signature).toEqual('bW9ja1NpZ25hdHVyZQ'); - expect(response.response.userHandle).toEqual('f4pdy3fpA34'); -}); + const response = await startAuthentication({ optionsJSON: goodOpts1 }); -test("should throw error if WebAuthn isn't supported", async () => { - mockSupportsWebAuthn.mockReturnValue(false); + assertEquals(response.rawId, 'Zm9vYmFy'); + assertEquals(response.response.authenticatorData, 'AQIDBA'); + assertEquals(response.response.clientDataJSON, 'BQYHCA'); + assertEquals(response.response.signature, 'CQABAg'); + assertEquals(response.response.userHandle, 'AwQFBg'); + }); - await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects.toThrow( - 'WebAuthn is not supported in this browser', - ); -}); + it("should throw error if WebAuthn isn't supported", async () => { + _browserSupportsWebAuthnInternals.stubThis = () => false; -test('should throw error if assertion is cancelled for some reason', async () => { - mockNavigatorGet.mockImplementation((): Promise => { - return new Promise((resolve) => { - resolve(null); - }); + await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + Error, + 'WebAuthn is not supported in this browser', + ); }); - await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects.toThrow( - 'Authentication was not completed', - ); -}); + it('should throw error if assertion is cancelled for some reason', async () => { + globalThis.navigator.credentials.get = async () => null; -test('should handle UTF-8 challenges', async () => { - await startAuthentication({ optionsJSON: goodOpts2UTF8 }); - - const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; - - expect(new Uint8Array(argsPublicKey.challenge)).toEqual( - new Uint8Array([ - 227, - 130, - 132, - 227, - 130, - 140, - 227, - 130, - 132, - 227, - 130, - 140, - 227, - 129, - 160, - 227, - 129, - 156, - ]), - ); -}); + await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + Error, + 'Authentication was not completed', + ); + }); -test('should send extensions to authenticator if present in options', async () => { - const extensions: AuthenticationExtensionsClientInputs = { - credProps: true, - appid: 'appidHere', - // @ts-ignore: Send arbitrary extensions - uvm: true, - // @ts-ignore: Send arbitrary extensions - appidExclude: 'appidExcludeHere', - }; - const optsWithExts: PublicKeyCredentialRequestOptionsJSON = { - ...goodOpts1, - extensions, - }; - await startAuthentication({ optionsJSON: optsWithExts }); - - const argsExtensions = mockNavigatorGet.mock.calls[0][0].publicKey.extensions; - - expect(argsExtensions).toEqual(extensions); -}); + it('should handle UTF-8 challenges', async () => { + await startAuthentication({ optionsJSON: goodOpts2UTF8 }); + + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + + assertEquals( + new Uint8Array(argsPublicKey.challenge as ArrayBuffer), + new Uint8Array([ + 227, + 130, + 132, + 227, + 130, + 140, + 227, + 130, + 132, + 227, + 130, + 140, + 227, + 129, + 160, + 227, + 129, + 156, + ]), + ); + }); -test('should not set any extensions if not present in options', async () => { - await startAuthentication({ optionsJSON: goodOpts1 }); + it('should send extensions to authenticator if present in options', async () => { + const extensions: AuthenticationExtensionsClientInputs = { + credProps: true, + appid: 'appidHere', + // @ts-ignore: Send arbitrary extensions + uvm: true, + // @ts-ignore: Send arbitrary extensions + appidExclude: 'appidExcludeHere', + }; + const optsWithExts: PublicKeyCredentialRequestOptionsJSON = { + ...goodOpts1, + extensions, + }; + await startAuthentication({ optionsJSON: optsWithExts }); + + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + const argsExtensions = argsPublicKey.extensions; + + assertEquals(argsExtensions, extensions); + }); - const argsExtensions = mockNavigatorGet.mock.calls[0][0].publicKey.extensions; + it('should not set any extensions if not present in options', async () => { + await startAuthentication({ optionsJSON: goodOpts1 }); - expect(argsExtensions).toEqual(undefined); -}); + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + const argsExtensions = argsPublicKey.extensions; -test('should include extension results', async () => { - const extResults: AuthenticationExtensionsClientOutputs = { - appid: true, - credProps: { - rk: true, - }, - }; + assertEquals(argsExtensions, undefined); + }); + + it('should include extension results', async () => { + const extResults: AuthenticationExtensionsClientOutputs = { + appid: true, + credProps: { + rk: true, + }, + }; - // Mock extension return values from authenticator - mockNavigatorGet.mockImplementation((): Promise => { - return new Promise((resolve) => { - resolve({ response: {}, getClientExtensionResults: () => extResults }); + // Mock extension return values from authenticator + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials.get = async () => ({ + response: {}, + getClientExtensionResults: () => extResults, }); + + // Extensions aren't present in this object, but it doesn't matter since we're faking the response + const response = await startAuthentication({ optionsJSON: goodOpts1 }); + + assertEquals(response.clientExtensionResults, extResults); }); - // Extensions aren't present in this object, but it doesn't matter since we're faking the response - const response = await startAuthentication({ optionsJSON: goodOpts1 }); + it('should include extension results when no extensions specified', async () => { + const response = await startAuthentication({ optionsJSON: goodOpts1 }); - expect(response.clientExtensionResults).toEqual(extResults); -}); + assertEquals(response.clientExtensionResults, {}); + }); -test('should include extension results when no extensions specified', async () => { - const response = await startAuthentication({ optionsJSON: goodOpts1 }); + it('should support "cable" transport', async () => { + const opts: PublicKeyCredentialRequestOptionsJSON = { + ...goodOpts1, + allowCredentials: [ + { + ...goodOpts1.allowCredentials![0], + transports: ['cable'], + }, + ], + }; - expect(response.clientExtensionResults).toEqual({}); -}); + await startAuthentication({ optionsJSON: opts }); -test('should support "cable" transport', async () => { - const opts: PublicKeyCredentialRequestOptionsJSON = { - ...goodOpts1, - allowCredentials: [ - { - ...goodOpts1.allowCredentials![0], - transports: ['cable'], - }, - ], - }; + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; - await startAuthentication({ optionsJSON: opts }); + assertEquals(argsPublicKey.allowCredentials?.[0]?.transports?.[0], 'cable'); + }); - expect( - mockNavigatorGet.mock.calls[0][0].publicKey.allowCredentials[0] - .transports[0], - ).toEqual( - 'cable', - ); -}); + it('should cancel an existing call when executed again', async () => { + const abortSpy = spy(AbortController.prototype, 'abort'); -test('should cancel an existing call when executed again', async () => { - const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + // Fire off a request and immediately attempt a second one + startAuthentication({ optionsJSON: goodOpts1 }); + await startAuthentication({ optionsJSON: goodOpts1 }); + assertSpyCalls(abortSpy, 1); + }); - // Fire off a request and immediately attempt a second one - startAuthentication({ optionsJSON: goodOpts1 }); - await startAuthentication({ optionsJSON: goodOpts1 }); - expect(abortSpy).toHaveBeenCalledTimes(1); -}); + it('should set up autofill a.k.a. Conditional UI', async () => { + const opts: PublicKeyCredentialRequestOptionsJSON = { + ...goodOpts1, + allowCredentials: [ + { + ...goodOpts1.allowCredentials![0], + transports: ['cable'], + }, + ], + }; + + // Prepare a simple HTML doc + const dom = new JSDOM(` +
+ + + +
+ `); + globalThis.document = dom.window.document; + + // @ts-ignore: Pretend this is a browser that + globalThis.PublicKeyCredential = () => {}; + _browserSupportsWebAuthnAutofillInternals.stubThis = async () => true; + + await startAuthentication({ optionsJSON: opts, useBrowserAutofill: true }); + + const args = getSpy.calls.at(0)?.args[0] as CredentialRequestOptions; + const argsPublicKey = args.publicKey!; + + // The most important bit + assertEquals(args.mediation, 'conditional'); + // The latest version of https://github.com/w3c/webauthn/pull/1576 says allowCredentials should + // be an "empty list", as opposed to being undefined + assertExists(argsPublicKey.allowCredentials); + assertEquals(argsPublicKey.allowCredentials.length, 0); + + // @ts-ignore: Cleanup + delete globalThis.PublicKeyCredential; + // @ts-ignore: Cleanup + delete globalThis.document; + }); -test('should set up autofill a.k.a. Conditional UI', async () => { - const opts: PublicKeyCredentialRequestOptionsJSON = { - ...goodOpts1, - allowCredentials: [ - { - ...goodOpts1.allowCredentials![0], - transports: ['cable'], - }, - ], - }; - document.body.innerHTML = ` -
- - - -
- `; - - await startAuthentication({ optionsJSON: opts, useBrowserAutofill: true }); - - // The most important bit - expect(mockNavigatorGet.mock.calls[0][0].mediation).toEqual('conditional'); - // The latest version of https://github.com/w3c/webauthn/pull/1576 says allowCredentials should - // be an "empty list", as opposed to being undefined - expect(mockNavigatorGet.mock.calls[0][0].publicKey.allowCredentials) - .toBeDefined(); - expect(mockNavigatorGet.mock.calls[0][0].publicKey.allowCredentials.length) - .toEqual(0); -}); + it('should set up conditional UI if "webauthn" is the only autocomplete token', async () => { + /** + * According to WHATWG "webauthn" can be the only token in the autocomplete attribute: + * https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens + */ + const dom = new JSDOM(` +
+ + + +
+ `); + globalThis.document = dom.window.document; + + // We just want to ensure that `startAuthentication()` doesn't error out here + const resp = await startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }); + assert(resp); + + // @ts-ignore: Cleanup + delete globalThis.document; + }); -test('should set up conditional UI if "webauthn" is the only autocomplete token', async () => { - /** - * According to WHATWG "webauthn" can be the only token in the autocomplete attribute: - * https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens - */ - document.body.innerHTML = ` -
- - - -
- `; + it('should throw error if autofill not supported', async () => { + _browserSupportsWebAuthnAutofillInternals.stubThis = async () => false; - await expect(startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true })).resolves; -}); + await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }), + Error, + 'does not support WebAuthn autofill', + ); + }); -test('should throw error if autofill not supported', async () => { - mockSupportsAutofill.mockResolvedValue(false); + it('should throw error if no acceptable is found', async () => { + // is missing "webauthn" from the autocomplete attribute + const dom = new JSDOM(` +
+ + + +
+ `); + globalThis.document = dom.window.document; + + await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }), + Error, + 'No ', + ); + + // @ts-ignore: Cleanup + delete globalThis.document; + }); - const rejected = await expect( - startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }), - ).rejects; - rejected.toThrow(Error); - rejected.toThrow(/does not support webauthn autofill/i); -}); + it('should not throw error when autofill input verification flag is false', async () => { + // No suitable is present in the "light DOM", which would normally raise... + const dom = new JSDOM(''); + globalThis.document = dom.window.document; -test('should throw error if no acceptable is found', async () => { - // is missing "webauthn" from the autocomplete attribute - document.body.innerHTML = ` -
+ // ...But a suitable IS inside of a web component's "shadow DOM" and we know it + const swanAutofill = globalThis.document.querySelector('swan-autofill'); + const shadowRoot = swanAutofill!.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ` - - -
- `; - - const rejected = await expect( - startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }), - ).rejects; - rejected.toThrow(Error); - rejected.toThrow(/no /i); -}); - -test('should not throw error when autofill input verification flag is false', async () => { - // No suitable is present in the "light DOM", which would normally raise... - document.body.innerHTML = ''; - - // ...But a suitable IS inside of a web component's "shadow DOM" and we know it - const swanAutofill = document.querySelector('swan-autofill'); - const shadowRoot = swanAutofill!.attachShadow({ mode: 'open' }); - shadowRoot.innerHTML = ` - - - `; - - await expect( - startAuthentication({ + + `; + + // We just want to ensure that `startAuthentication()` doesn't error out here + const resp = await startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true, verifyBrowserAutofillInput: false, - }), - ).resolves; -}); + }); + assert(resp); -test('should throw error if "webauthn" is not final autocomplete token', async () => { - /** - * According to WHATWG "webauthn" must be the final token in the autocomplete attribute when - * multiple tokens are present: - * https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens - */ - document.body.innerHTML = ` -
- - - -
- `; - - const rejected = await expect( - startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }), - ).rejects; - rejected.toThrow(Error); - rejected.toThrow(/no /i); -}); + // @ts-ignore: Cleanup + delete globalThis.document; + }); -test('should return authenticatorAttachment if present', async () => { - // Mock extension return values from authenticator - mockNavigatorGet.mockImplementation((): Promise => { - return new Promise((resolve) => { - resolve({ - response: {}, - getClientExtensionResults: () => {}, - authenticatorAttachment: 'cross-platform', - }); - }); + it('should throw error if "webauthn" is not final autocomplete token', async () => { + /** + * According to WHATWG "webauthn" must be the final token in the autocomplete attribute when + * multiple tokens are present: + * https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens + */ + const dom = new JSDOM(` +
+ + + +
+ `); + globalThis.document = dom.window.document; + + await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1, useBrowserAutofill: true }), + Error, + 'No ', + ); + + // @ts-ignore: Cleanup + delete globalThis.document; }); - const response = await startAuthentication({ optionsJSON: goodOpts1 }); + it('should return authenticatorAttachment if present', async () => { + // Mock extension return values from authenticator + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials.get = async () => ({ + response: {}, + getClientExtensionResults: () => {}, + authenticatorAttachment: 'cross-platform', + }); + + const response = await startAuthentication({ optionsJSON: goodOpts1 }); - expect(response.authenticatorAttachment).toEqual('cross-platform'); + assertEquals(response.authenticatorAttachment, 'cross-platform'); + }); }); describe('WebAuthnError', () => { - describe('AbortError', () => { - const AbortError = generateCustomError('AbortError'); - - /** - * We can't actually test this because nothing in startAuthentication() propagates the abort - * signal. But if you invoked WebAuthn via this and then manually sent an abort signal I guess - * this will catch. - * - * As a matter of fact I couldn't actually get any browser to respect the abort signal... - */ - test.skip('should identify abort signal', async () => { - mockNavigatorGet.mockRejectedValueOnce(AbortError); - - const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; - rejected.toThrow(WebAuthnError); - rejected.toThrow(/abort signal/i); - rejected.toHaveProperty('name', 'AbortError'); - rejected.toHaveProperty('code', 'ERROR_CEREMONY_ABORTED'); - rejected.toHaveProperty('cause', AbortError); - }); - }); + // describe('AbortError', () => { + // const AbortError = generateCustomError('AbortError'); + + // /** + // * We can't actually test this because nothing in startAuthentication() propagates the abort + // * signal. But if you invoked WebAuthn via this and then manually sent an abort signal I guess + // * this will catch. + // * + // * As a matter of fact I couldn't actually get any browser to respect the abort signal... + // */ + // it.skip('should identify abort signal', async () => { + // mockNavigatorGet.mockRejectedValueOnce(AbortError); + + // const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; + // rejected.toThrow(WebAuthnError); + // rejected.toThrow(/abort signal/i); + // rejected.toHaveProperty('name', 'AbortError'); + // rejected.toHaveProperty('code', 'ERROR_CEREMONY_ABORTED'); + // rejected.toHaveProperty('cause', AbortError); + // }); + // }); describe('NotAllowedError', () => { - test('should pass through error message (iOS Safari - Operation failed)', async () => { + it('should pass through error message (iOS Safari - Operation failed)', async () => { /** * Thrown when biometric is not enrolled, or a Safari bug prevents conditional UI from being * aborted properly between page reloads. @@ -443,17 +481,26 @@ describe('WebAuthnError', () => { 'NotAllowedError', 'Operation failed.', ); - mockNavigatorGet.mockRejectedValueOnce(NotAllowedError); - - const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; - rejected.toThrow(Error); - rejected.toThrow(/operation failed/i); - rejected.toHaveProperty('name', 'NotAllowedError'); - rejected.toHaveProperty('code', 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY'); - rejected.toHaveProperty('cause', NotAllowedError); + + const getSpy = spy(async () => { + throw NotAllowedError; + }); + + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials = { get: getSpy }; + + const rejected = await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + WebAuthnError, + 'Operation failed', + ); + + assertEquals(rejected.name, 'NotAllowedError'); + assertEquals(rejected.code, 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY'); + assertEquals(rejected.cause, NotAllowedError); }); - test('should pass through error message (Chrome M110 - Bad TLS Cert)', async () => { + it('should pass through error message (Chrome M110 - Bad TLS Cert)', async () => { /** * Starting from Chrome M110, WebAuthn is blocked if the site is being displayed on a URL with * TLS certificate issues. This includes during development. @@ -464,73 +511,100 @@ describe('WebAuthnError', () => { 'NotAllowedError', 'WebAuthn is not supported on sites with TLS certificate errors.', ); - mockNavigatorGet.mockRejectedValueOnce(NotAllowedError); - - const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; - rejected.toThrow(Error); - rejected.toThrow(/sites with TLS certificate errors/i); - rejected.toHaveProperty('name', 'NotAllowedError'); - rejected.toHaveProperty('code', 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY'); - rejected.toHaveProperty('cause', NotAllowedError); + + const getSpy = spy(async () => { + throw NotAllowedError; + }); + + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials = { get: getSpy }; + + const rejected = await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + WebAuthnError, + 'sites with TLS certificate errors', + ); + + assertEquals(rejected.name, 'NotAllowedError'); + assertEquals(rejected.code, 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY'); + assertEquals(rejected.cause, NotAllowedError); }); }); describe('SecurityError', () => { const SecurityError = generateCustomError('SecurityError'); - let _originalHostName: string; - beforeEach(() => { - _originalHostName = window.location.hostname; - }); + const getSpy = spy(async () => { + throw SecurityError; + }); + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials = { get: getSpy }; - afterEach(() => { - window.location.hostname = _originalHostName; + // @ts-ignore + globalThis.location = { hostname: '' } as unknown; + // @ts-ignore + globalThis.window = globalThis; }); - test('should identify invalid domain', async () => { - window.location.hostname = '1.2.3.4'; + it('should identify invalid domain', async () => { + globalThis.location.hostname = '1.2.3.4'; - mockNavigatorGet.mockRejectedValueOnce(SecurityError); + const rejected = await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + WebAuthnError, + '1.2.3.4 is an invalid domain' + ); - const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; - rejected.toThrowError(WebAuthnError); - rejected.toThrow(/1\.2\.3\.4/); - rejected.toThrow(/invalid domain/i); - rejected.toHaveProperty('name', 'SecurityError'); - rejected.toHaveProperty('code', 'ERROR_INVALID_DOMAIN'); - rejected.toHaveProperty('cause', SecurityError); + assertEquals(rejected.name, 'SecurityError'); + assertEquals(rejected.code, 'ERROR_INVALID_DOMAIN'); + assertEquals(rejected.cause, SecurityError); }); - test('should identify invalid RP ID', async () => { - window.location.hostname = 'simplewebauthn.com'; + it('should identify invalid RP ID', async () => { + globalThis.location.hostname = 'simplewebauthn.com'; - mockNavigatorGet.mockRejectedValueOnce(SecurityError); + const rejected = await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + WebAuthnError, + `RP ID "${goodOpts1.rpId}" is invalid for this domain` + ); - const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; - rejected.toThrowError(WebAuthnError); - rejected.toThrow(goodOpts1.rpId); - rejected.toThrow(/invalid for this domain/i); - rejected.toHaveProperty('name', 'SecurityError'); - rejected.toHaveProperty('code', 'ERROR_INVALID_RP_ID'); - rejected.toHaveProperty('cause', SecurityError); + assertEquals(rejected.name, 'SecurityError'); + assertEquals(rejected.code, 'ERROR_INVALID_RP_ID'); + assertEquals(rejected.cause, SecurityError); }); }); describe('UnknownError', () => { const UnknownError = generateCustomError('UnknownError'); - test('should identify potential authenticator issues', async () => { - mockNavigatorGet.mockRejectedValueOnce(UnknownError); - - const rejected = await expect(startAuthentication({ optionsJSON: goodOpts1 })).rejects; - rejected.toThrow(WebAuthnError); - rejected.toThrow(/authenticator/i); - rejected.toThrow(/unable to process the specified options/i); - rejected.toThrow(/could not create a new assertion signature/i); - rejected.toHaveProperty('name', 'UnknownError'); - rejected.toHaveProperty('code', 'ERROR_AUTHENTICATOR_GENERAL_ERROR'); - rejected.toHaveProperty('cause', UnknownError); + beforeEach(() => { + const getSpy = spy(async () => { + throw UnknownError; + }); + // @ts-ignore: Super lame, making me stub out credman like this + globalThis.navigator.credentials = { get: getSpy }; + + // @ts-ignore + globalThis.location = { hostname: '' } as unknown; + // @ts-ignore + globalThis.window = globalThis; + }); + + it('should identify potential authenticator issues', async () => { + const rejected = await assertRejects( + () => startAuthentication({ optionsJSON: goodOpts1 }), + WebAuthnError, + 'authenticator', + ); + + assertStringIncludes(rejected.message, 'unable to process the specified options'); + assertStringIncludes(rejected.message, 'could not create a new assertion signature'); + + assertEquals(rejected.name, 'UnknownError'); + assertEquals(rejected.code, 'ERROR_AUTHENTICATOR_GENERAL_ERROR'); + assertEquals(rejected.cause, UnknownError); }); }); });