Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/448-manual-ceremony-cancellation #449

Merged
merged 6 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions packages/browser/src/helpers/webAuthnAbortService.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,50 @@
import { webauthnAbortService } from './webAuthnAbortService';
import { WebAuthnAbortService } from './webAuthnAbortService';

test('should create a new abort signal every time', () => {
const signal1 = webauthnAbortService.createNewAbortSignal();
const signal2 = webauthnAbortService.createNewAbortSignal();
const signal1 = WebAuthnAbortService.createNewAbortSignal();
const signal2 = WebAuthnAbortService.createNewAbortSignal();

expect(signal2).not.toBe(signal1);
});

test('should call abort() with AbortError on existing controller when creating a new signal', () => {
// Populate `.controller`
webauthnAbortService.createNewAbortSignal();
WebAuthnAbortService.createNewAbortSignal();

// Spy on the existing instance of AbortController
const abortSpy = jest.fn();
// @ts-ignore: Ignore the fact that `controller` is private
webauthnAbortService.controller.abort = abortSpy;
WebAuthnAbortService.controller.abort = abortSpy;

// Generate a new signal, which should call `abort()` on the existing controller
webauthnAbortService.createNewAbortSignal();
WebAuthnAbortService.createNewAbortSignal();
expect(abortSpy).toHaveBeenCalledTimes(1);

// Make sure we raise an AbortError so it can be detected correctly
const abortReason = abortSpy.mock.calls[0][0];
expect(abortReason).toBeInstanceOf(Error);
expect(abortReason.name).toEqual('AbortError');
});

test('should cancel active WebAuthn ceremony when manually cancelled', () => {
// Populate `.controller`
WebAuthnAbortService.createNewAbortSignal();

// Spy on the existing instance of AbortController
const abortSpy = jest.fn();
// @ts-ignore: Ignore the fact that `controller` is private
WebAuthnAbortService.controller.abort = abortSpy;

// Cancel the in-flight ceremony, which should call `abort()` on the existing controller
WebAuthnAbortService.cancelCeremony();
expect(abortSpy).toHaveBeenCalledTimes(1);

// Make sure we raise an AbortError so it can be detected correctly
const abortReason = abortSpy.mock.calls[0][0];
expect(abortReason).toBeInstanceOf(Error);
expect(abortReason.name).toEqual('AbortError');

// Ensure that we don't set up a new AbortController because it's unnecessary to do so
// @ts-ignore: Ignore the fact that `controller` is private
expect(WebAuthnAbortService.controller).toBeUndefined();
});
33 changes: 26 additions & 7 deletions packages/browser/src/helpers/webAuthnAbortService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
/**
* A way to cancel an existing WebAuthn request, for example to cancel a
* WebAuthn autofill authentication request for a manual authentication attempt.
*/
class WebAuthnAbortService {
class BaseWebAuthnAbortService {
private controller: AbortController | undefined;

/**
* Prepare an abort signal that will help support multiple auth attempts without needing to
* reload the page
* reload the page. This is automatically called whenever `startRegistration()` and
* `startAuthentication()` are called.
*/
createNewAbortSignal() {
// Abort any existing calls to navigator.credentials.create() or navigator.credentials.get()
Expand All @@ -24,6 +21,28 @@ class WebAuthnAbortService {
this.controller = newController;
return newController.signal;
}

/**
* Manually cancel any active WebAuthn registration or authentication attempt.
*/
cancelCeremony() {
if (this.controller) {
const abortError = new Error(
'Manually cancelling existing WebAuthn API call',
);
abortError.name = 'AbortError';
this.controller.abort(abortError);

this.controller = undefined;
}
}
}

export const webauthnAbortService = new WebAuthnAbortService();
/**
* A service singleton to help ensure that only a single WebAuthn ceremony is active at a time.
*
* Users of **@simplewebauthn/browser** shouldn't typically need to use this, but it can help e.g.
* developers building projects that use client-side routing to better control the behavior of
* their UX in response to router navigation events.
*/
export const WebAuthnAbortService = new BaseWebAuthnAbortService();
4 changes: 4 additions & 0 deletions packages/browser/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ test('should export method `base64URLStringToBuffer`', () => {
test('should export method `bufferToBase64URLString`', () => {
expect(index.bufferToBase64URLString).toBeDefined();
});

test('should export singleton `WebAuthnAbortService`', () => {
expect(index.WebAuthnAbortService).toBeDefined();
});
2 changes: 2 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { platformAuthenticatorIsAvailable } from './helpers/platformAuthenticato
import { browserSupportsWebAuthnAutofill } from './helpers/browserSupportsWebAuthnAutofill';
import { base64URLStringToBuffer } from './helpers/base64URLStringToBuffer';
import { bufferToBase64URLString } from './helpers/bufferToBase64URLString';
import { WebAuthnAbortService } from './helpers/webAuthnAbortService';

export {
base64URLStringToBuffer,
Expand All @@ -18,6 +19,7 @@ export {
platformAuthenticatorIsAvailable,
startAuthentication,
startRegistration,
WebAuthnAbortService,
};

export type { WebAuthnErrorCode } from './helpers/webAuthnError';
5 changes: 2 additions & 3 deletions packages/browser/src/methods/startAuthentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { utf8StringToBuffer } from '../helpers/utf8StringToBuffer';
import { bufferToBase64URLString } from '../helpers/bufferToBase64URLString';
import { WebAuthnError } from '../helpers/webAuthnError';
import { generateCustomError } from '../helpers/__jest__/generateCustomError';
import { webauthnAbortService } from '../helpers/webAuthnAbortService';
import { WebAuthnAbortService } from '../helpers/webAuthnAbortService';

import { startAuthentication } from './startAuthentication';

Expand Down Expand Up @@ -62,8 +62,7 @@ beforeEach(() => {
mockSupportsAutofill.mockResolvedValue(true);

// Reset the abort service so we get an accurate call count
// @ts-ignore: Ignore the fact that `controller` is private
webauthnAbortService.controller = undefined;
WebAuthnAbortService.cancelCeremony();
});

afterEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/methods/startAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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';
import { WebAuthnAbortService } from '../helpers/webAuthnAbortService';
import { toAuthenticatorAttachment } from '../helpers/toAuthenticatorAttachment';

/**
Expand Down Expand Up @@ -79,7 +79,7 @@ export async function startAuthentication(
// Finalize options
options.publicKey = publicKey;
// Set up the ability to cancel this request if the user attempts another
options.signal = webauthnAbortService.createNewAbortSignal();
options.signal = WebAuthnAbortService.createNewAbortSignal();

// Wait for the user to complete assertion
let credential;
Expand Down
5 changes: 2 additions & 3 deletions packages/browser/src/methods/startRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { generateCustomError } from '../helpers/__jest__/generateCustomError';
import { browserSupportsWebAuthn } from '../helpers/browserSupportsWebAuthn';
import { bufferToBase64URLString } from '../helpers/bufferToBase64URLString';
import { WebAuthnError } from '../helpers/webAuthnError';
import { webauthnAbortService } from '../helpers/webAuthnAbortService';
import { WebAuthnAbortService } from '../helpers/webAuthnAbortService';

import { utf8StringToBuffer } from '../helpers/utf8StringToBuffer';

Expand Down Expand Up @@ -61,8 +61,7 @@ beforeEach(() => {
mockSupportsWebauthn.mockReturnValue(true);

// Reset the abort service so we get an accurate call count
// @ts-ignore: Ignore the fact that `controller` is private
webauthnAbortService.controller = undefined;
WebAuthnAbortService.cancelCeremony();
});

afterEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/methods/startRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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';
import { WebAuthnAbortService } from '../helpers/webAuthnAbortService';
import { toAuthenticatorAttachment } from '../helpers/toAuthenticatorAttachment';

/**
Expand Down Expand Up @@ -42,7 +42,7 @@ export async function startRegistration(
// Finalize options
const options: CredentialCreationOptions = { publicKey };
// Set up the ability to cancel this request if the user attempts another
options.signal = webauthnAbortService.createNewAbortSignal();
options.signal = WebAuthnAbortService.createNewAbortSignal();

// Wait for the user to complete attestation
let credential;
Expand Down
Loading