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 = `
-
- `;
-
- 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);
});
});
});