diff options
Diffstat (limited to 'packages/browser/src')
9 files changed, 232 insertions, 8 deletions
diff --git a/packages/browser/src/helpers/__mocks__/browserSupportsWebAuthnAutofill.ts b/packages/browser/src/helpers/__mocks__/browserSupportsWebAuthnAutofill.ts new file mode 100644 index 0000000..311b635 --- /dev/null +++ b/packages/browser/src/helpers/__mocks__/browserSupportsWebAuthnAutofill.ts @@ -0,0 +1,2 @@ +// We just need a simple mock so we can control whether this returns `true` or `false` +export const browserSupportsWebAuthnAutofill = jest.fn(); diff --git a/packages/browser/src/helpers/browserSupportsWebAuthnAutofill.ts b/packages/browser/src/helpers/browserSupportsWebAuthnAutofill.ts new file mode 100644 index 0000000..ffca77d --- /dev/null +++ b/packages/browser/src/helpers/browserSupportsWebAuthnAutofill.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { PublicKeyCredentialFuture } from '@simplewebauthn/typescript-types'; + +/** + * Determine if the browser supports conditional UI, so that WebAuthn credentials can + * be shown to the user in the browser's typical password autofill popup. + */ +export async function browserSupportsWebAuthnAutofill(): Promise<boolean> { + // Just for Chrome Canary right now; the PublicKeyCredential logic below is the real API + // @ts-ignore + if (navigator.credentials.conditionalMediationSupported) { + return true; + } + + /** + * I don't like the `as unknown` here but there's a `declare var PublicKeyCredential` in + * TS' DOM lib that's making it difficult for me to just go `as PublicKeyCredentialFuture` as I + * want. I think I'm fine with this for now since it's _supposed_ to be temporary, until TS types + * have a chance to catch up. + */ + const globalPublicKeyCredential = + window.PublicKeyCredential as unknown as PublicKeyCredentialFuture; + + return ( + globalPublicKeyCredential.isConditionalMediationAvailable !== undefined && + globalPublicKeyCredential.isConditionalMediationAvailable() + ); +} diff --git a/packages/browser/src/helpers/webAuthnAbortService.test.ts b/packages/browser/src/helpers/webAuthnAbortService.test.ts new file mode 100644 index 0000000..f4e6344 --- /dev/null +++ b/packages/browser/src/helpers/webAuthnAbortService.test.ts @@ -0,0 +1,42 @@ +import { webauthnAbortService } from './webAuthnAbortService'; + +test('should create a new abort signal every time', () => { + const signal1 = webauthnAbortService.createNewAbortSignal(); + const signal2 = webauthnAbortService.createNewAbortSignal(); + + expect(signal2).not.toBe(signal1); +}); + +test('should call abort() on existing controller when creating a new signal', () => { + // Populate `.controller` + webauthnAbortService.createNewAbortSignal(); + + // Spy on the existing instance of AbortController + const abortSpy = jest.fn(); + // @ts-ignore + webauthnAbortService.controller?.abort = abortSpy; + + // Generate a new signal, which should call `abort()` on the existing controller + webauthnAbortService.createNewAbortSignal(); + expect(abortSpy).toHaveBeenCalledTimes(1); +}); + +test('should reset controller', () => { + // Reset the service + webauthnAbortService.reset(); + + // Populate `.controller` + webauthnAbortService.createNewAbortSignal(); + + // Spy on the existing instance of AbortController + const abortSpy = jest.fn(); + // @ts-ignore + webauthnAbortService.controller?.abort = abortSpy; + + // Reset the service + webauthnAbortService.reset(); + + // Generate a new signal, which should NOT call `abort()` because the controller was cleared + webauthnAbortService.createNewAbortSignal(); + expect(abortSpy).toHaveBeenCalledTimes(0); +}); diff --git a/packages/browser/src/helpers/webAuthnAbortService.ts b/packages/browser/src/helpers/webAuthnAbortService.ts new file mode 100644 index 0000000..c60e6df --- /dev/null +++ b/packages/browser/src/helpers/webAuthnAbortService.ts @@ -0,0 +1,27 @@ +/** + * A way to cancel an existing WebAuthn request, for example to cancel a + * WebAuthn autofill authentication request for a manual authentication attempt. + */ +class WebAuthnAbortService { + private controller: AbortController | undefined; + + /** + * Prepare an abort signal that will help support multiple auth attempts without needing to + * reload the page + */ + createNewAbortSignal() { + // Abort any existing calls to navigator.credentials.create() or navigator.credentials.get() + if (this.controller) { + this.controller.abort(); + } + + this.controller = new AbortController(); + return this.controller.signal; + } + + reset() { + this.controller = undefined; + } +} + +export const webauthnAbortService = new WebAuthnAbortService(); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 4c83297..e288b7c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,14 +2,16 @@ * @packageDocumentation * @module @simplewebauthn/browser */ -import startRegistration from './methods/startRegistration'; -import startAuthentication from './methods/startAuthentication'; +import { startRegistration } from './methods/startRegistration'; +import { startAuthentication } from './methods/startAuthentication'; import { browserSupportsWebauthn } from './helpers/browserSupportsWebauthn'; import { platformAuthenticatorIsAvailable } from './helpers/platformAuthenticatorIsAvailable'; +import { browserSupportsWebAuthnAutofill } from './helpers/browserSupportsWebAuthnAutofill'; export { startRegistration, startAuthentication, browserSupportsWebauthn, + browserSupportsWebAuthnAutofill, platformAuthenticatorIsAvailable, }; diff --git a/packages/browser/src/methods/startAuthentication.test.ts b/packages/browser/src/methods/startAuthentication.test.ts index 658b67c..6f1b87b 100644 --- a/packages/browser/src/methods/startAuthentication.test.ts +++ b/packages/browser/src/methods/startAuthentication.test.ts @@ -6,17 +6,21 @@ import { } from '@simplewebauthn/typescript-types'; import { browserSupportsWebauthn } from '../helpers/browserSupportsWebauthn'; +import { browserSupportsWebAuthnAutofill } from '../helpers/browserSupportsWebAuthnAutofill'; import utf8StringToBuffer from '../helpers/utf8StringToBuffer'; import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; import { WebAuthnError } from '../helpers/structs'; import { generateCustomError } from '../helpers/__jest__/generateCustomError'; +import { webauthnAbortService } from '../helpers/webAuthnAbortService'; -import startAuthentication from './startAuthentication'; +import { startAuthentication } from './startAuthentication'; 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'; @@ -55,11 +59,13 @@ beforeEach(() => { }); mockSupportsWebauthn.mockReturnValue(true); + mockSupportsAutofill.mockResolvedValue(true); }); afterEach(() => { mockNavigatorGet.mockReset(); mockSupportsWebauthn.mockReset(); + mockSupportsAutofill.mockReset(); }); test('should convert options before passing to navigator.credentials.get(...)', async () => { @@ -219,6 +225,68 @@ test('should support "cable" transport', async () => { .toEqual("cable"); }); +test('should cancel an existing call when executed again', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + // Reset the abort service so we get an accurate call count + webauthnAbortService.reset(); + + // Fire off a request and immediately attempt a second one + startAuthentication(goodOpts1); + await startAuthentication(goodOpts1); + expect(abortSpy).toHaveBeenCalledTimes(1); +}); + +test('should set up autofill a.k.a. Conditional UI', async () => { + const opts: PublicKeyCredentialRequestOptionsJSON = { + ...goodOpts1, + allowCredentials: [ + { + ...goodOpts1.allowCredentials![0], + transports: ["cable"], + }, + ] + }; + document.body.innerHTML = ` + <form> + <label for="username">Username</label> + <input type="text" name="username" autocomplete="username webauthn" /> + <button type="submit">Submit</button> + </form> + `; + + await startAuthentication(opts, 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); +}); + +test('should throw error if autofill not supported', async () => { + mockSupportsAutofill.mockResolvedValue(false); + + const rejected = await expect(startAuthentication(goodOpts1, true)).rejects; + rejected.toThrow(Error); + rejected.toThrow(/does not support webauthn autofill/i); +}); + +test('should throw error if no acceptable <input> is found', async () => { + // <input> is missing "webauthn" from the autocomplete attribute + document.body.innerHTML = ` + <form> + <label for="username">Username</label> + <input type="text" name="username" autocomplete="username" /> + <button type="submit">Submit</button> + </form> + `; + + const rejected = await expect(startAuthentication(goodOpts1, true)).rejects; + rejected.toThrow(Error); + rejected.toThrow(/no <input>/i); +}); + describe('WebAuthnError', () => { describe('AbortError', () => { const AbortError = generateCustomError('AbortError'); diff --git a/packages/browser/src/methods/startAuthentication.ts b/packages/browser/src/methods/startAuthentication.ts index ee401a1..7887228 100644 --- a/packages/browser/src/methods/startAuthentication.ts +++ b/packages/browser/src/methods/startAuthentication.ts @@ -8,16 +8,21 @@ import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; import base64URLStringToBuffer from '../helpers/base64URLStringToBuffer'; import bufferToUTF8String from '../helpers/bufferToUTF8String'; import { browserSupportsWebauthn } from '../helpers/browserSupportsWebauthn'; +import { browserSupportsWebAuthnAutofill } from '../helpers/browserSupportsWebAuthnAutofill'; import toPublicKeyCredentialDescriptor from '../helpers/toPublicKeyCredentialDescriptor'; import { identifyAuthenticationError } from '../helpers/identifyAuthenticationError'; +import { webauthnAbortService } from '../helpers/webAuthnAbortService'; /** * Begin authenticator "login" via WebAuthn assertion * - * @param requestOptionsJSON Output from @simplewebauthn/server's generateAssertionOptions(...) + * @param requestOptionsJSON Output from **@simplewebauthn/server**'s generateAssertionOptions(...) + * @param useBrowserAutofill Initialize conditional UI to enable logging in via browser + * autofill prompts */ -export default async function startAuthentication( +export async function startAuthentication( requestOptionsJSON: PublicKeyCredentialRequestOptionsJSON, + useBrowserAutofill = false, ): Promise<AuthenticationCredentialJSON> { if (!browserSupportsWebauthn()) { throw new Error('WebAuthn is not supported in this browser'); @@ -37,7 +42,37 @@ export default async function startAuthentication( allowCredentials, }; - const options: CredentialRequestOptions = { publicKey }; + // Prepare options for `.get()` + const options: CredentialRequestOptions = {}; + + /** + * Set up the page to prompt the user to select a credential for authentication via the browser's + * input autofill mechanism. + */ + if (useBrowserAutofill) { + if (!(await browserSupportsWebAuthnAutofill())) { + throw Error('Browser does not support WebAuthn autofill'); + } + + // Check for an <input> with "webauthn" in its `autocomplete` attribute + const eligibleInputs = document.querySelectorAll("input[autocomplete*='webauthn']"); + + // WebAuthn autofill requires at least one valid input + if (eligibleInputs.length < 1) { + throw Error('No <input> with `"webauthn"` in its `autocomplete` attribute was detected'); + } + + // `CredentialMediationRequirement` doesn't know about "conditional" yet as of + // typescript@4.6.3 + options.mediation = 'conditional' as CredentialMediationRequirement; + // Conditional UI requires an empty allow list + publicKey.allowCredentials = []; + } + + // Finalize options + options.publicKey = publicKey; + // Set up the ability to cancel this request if the user attempts another + options.signal = webauthnAbortService.createNewAbortSignal(); // Wait for the user to complete assertion let credential; @@ -45,6 +80,8 @@ export default async function startAuthentication( credential = (await navigator.credentials.get(options)) as AuthenticationCredential; } catch (err) { throw identifyAuthenticationError({ error: err as Error, options }); + } finally { + webauthnAbortService.reset(); } if (!credential) { diff --git a/packages/browser/src/methods/startRegistration.test.ts b/packages/browser/src/methods/startRegistration.test.ts index fcd4a2c..93400c4 100644 --- a/packages/browser/src/methods/startRegistration.test.ts +++ b/packages/browser/src/methods/startRegistration.test.ts @@ -8,10 +8,11 @@ import { generateCustomError } from '../helpers/__jest__/generateCustomError'; import { browserSupportsWebauthn } from '../helpers/browserSupportsWebauthn'; import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; import { WebAuthnError } from '../helpers/structs'; +import { webauthnAbortService } from '../helpers/webAuthnAbortService'; import utf8StringToBuffer from '../helpers/utf8StringToBuffer'; -import startRegistration from './startRegistration'; +import { startRegistration } from './startRegistration'; jest.mock('../helpers/browserSupportsWebauthn'); @@ -211,6 +212,17 @@ test('should return "cable" transport from response', async () => { expect(response.transports).toEqual(["cable"]); }); +test('should cancel an existing call when executed again', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + // Reset the abort service so we get an accurate call count + webauthnAbortService.reset(); + + // Fire off a request and immediately attempt a second one + startRegistration(goodOpts1); + await startRegistration(goodOpts1); + expect(abortSpy).toHaveBeenCalledTimes(1); +}); + describe('WebAuthnError', () => { describe('AbortError', () => { const AbortError = generateCustomError('AbortError'); diff --git a/packages/browser/src/methods/startRegistration.ts b/packages/browser/src/methods/startRegistration.ts index 4037092..80fbe31 100644 --- a/packages/browser/src/methods/startRegistration.ts +++ b/packages/browser/src/methods/startRegistration.ts @@ -10,13 +10,14 @@ import base64URLStringToBuffer from '../helpers/base64URLStringToBuffer'; import { browserSupportsWebauthn } from '../helpers/browserSupportsWebauthn'; import toPublicKeyCredentialDescriptor from '../helpers/toPublicKeyCredentialDescriptor'; import { identifyRegistrationError } from '../helpers/identifyRegistrationError'; +import { webauthnAbortService } from '../helpers/webAuthnAbortService'; /** * Begin authenticator "registration" via WebAuthn attestation * * @param creationOptionsJSON Output from @simplewebauthn/server's generateRegistrationOptions(...) */ -export default async function startRegistration( +export async function startRegistration( creationOptionsJSON: PublicKeyCredentialCreationOptionsJSON, ): Promise<RegistrationCredentialJSON> { if (!browserSupportsWebauthn()) { @@ -34,7 +35,10 @@ export default async function startRegistration( excludeCredentials: creationOptionsJSON.excludeCredentials.map(toPublicKeyCredentialDescriptor), }; + // Finalize options const options: CredentialCreationOptions = { publicKey }; + // Set up the ability to cancel this request if the user attempts another + options.signal = webauthnAbortService.createNewAbortSignal(); // Wait for the user to complete attestation let credential; @@ -42,6 +46,8 @@ export default async function startRegistration( credential = (await navigator.credentials.create(options)) as RegistrationCredential; } catch (err) { throw identifyRegistrationError({ error: err as Error, options }); + } finally { + webauthnAbortService.reset(); } if (!credential) { |