summaryrefslogtreecommitdiffhomepage
path: root/packages/browser/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/browser/src')
-rw-r--r--packages/browser/src/helpers/__mocks__/browserSupportsWebAuthnAutofill.ts2
-rw-r--r--packages/browser/src/helpers/browserSupportsWebAuthnAutofill.ts28
-rw-r--r--packages/browser/src/helpers/webAuthnAbortService.test.ts42
-rw-r--r--packages/browser/src/helpers/webAuthnAbortService.ts27
-rw-r--r--packages/browser/src/index.ts6
-rw-r--r--packages/browser/src/methods/startAuthentication.test.ts70
-rw-r--r--packages/browser/src/methods/startAuthentication.ts43
-rw-r--r--packages/browser/src/methods/startRegistration.test.ts14
-rw-r--r--packages/browser/src/methods/startRegistration.ts8
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) {