mylofi / webauthn-local-client

Browser-only utils for locally managing WebAuthn (passkey) API
https://mylofi.github.io/webauthn-local-client/
MIT License
116 stars 4 forks source link
authentication biometric-authentication cryptography passkeys vite-plugin webauthn webpack-pluign

WebAuthn Local Client

npm Module License

WebAuthn-Local-Client is a web (browser) client for locally managing the "Web Authentication" (WebAuthn) API.


Check out vella.ai/auth for a demo app using this library for local-only authentication with WebAuthn and local encryption.

Library Tests (Demo)


The WebAuthn API lets users of web applications avoid the long-troubled use of (often insecure) passwords, and instead present personal biometric factors (Touch-ID, Face-ID, etc) via their device to prove their identity for login/authentication, authorization, etc. Traditionally, this authentication process involves an application interacting with a FIDO2 Server to initiate, verify, and store responses to such WebAuthn API interactions.

However, the intended use-case for WebAuthn-Local-Client is to allow Local-First Web applications to handle user login locally on a device, without any server (FIDO2 or otherwise).

Note: This package may be used in combination with a traditional FIDO2 server application architecture, but does not include any specific functionality for that purpose. For server integration with WebAuthn, you may instead consider alternative libraries, like this one or this one.

Deployment / Import

npm install @lo-fi/webauthn-local-client

The @lo-fi/webauthn-local-client npm package includes a dist/ directory with all files you need to deploy WebAuthn-Local-Client (and its dependencies) into your application/project.

Note: If you obtain this library via git instead of npm, you'll need to build dist/ manually before deployment.

WebAuthn Supported?

To check if WebAuthn API and functionality is supported on the device:

import { supportsWebAuthn } from "..";

if (supportsWebAuthn) {
    // welcome to the future, without passwords!
}
else {
    // sigh, use fallback authentication, like
    // icky passwords :(
}

To check if passkey autofill (aka "Conditional Mediation") is supported on the device:

import { supportsConditionalMediation } from "..";

if (supportsConditionalMediation) {
    // provide an <input> and UX for user to
    // click on, to select their passkey
    // credential via autofill
}
else {
    // provide UX for user to trigger
    // authentication, where the browser will
    // provide a modal for the user to select
    // their credential
}

Registering a new credential

To register a new credential in a WebAuthn-exposed authenticator, use register():

import { regDefaults, register } from "..";

// optional:
var regOptions = regDefaults({
    // ..options..
});

var regResult = await register(regOptions);

register() returns a promise that will resolve to an object (regResult above) if successful. Otherwise, it will be rejected (await will throw).

Register Configuration

To configure the registration options, but include all the defaults for anything not being overridden, use regDefaults(..).

Typical register() configuration options:

See regDefaults() function signature for more options.

Registration Result

register() returns a promise that's fulfilled (success or rejection) once the user completes or cancels a credential (aka "passkey") registration with their device's authenticator.

If register() completes successfully, the return value (regResult above) will include both a request and response property:

Attestation

This library by default does NOT ask for any attestation information (i.e., attestation: "none" in regDefaults()) from a device authenticator -- for verifying the authenticity of its response via certificate chains -- nor does it perform any such verification on the registration result. Such verification is quite a complex process, best suited for a FIDO2 Server, so it's out of scope for this library's intended local-in-browser-only operation.

You can however override the configuration (via attestation: "..") for register(..) to ask for attestation information, and pass that along (from response.raw) to a separate verification process (on server, or in browser) as desired.

Typically, though, web applications assume that if a device is compromised in such a way that it's able to bypass/MITM a device authenticator, the app is not the appropriate or responsible party to detect or alert an end-user to such. Most applications skip verifying attestation certificate chains, unless there's very specific, elevated-risk security reasons they must do so.

Authenticating with an existing credential

To authenticate (i.e., perform an assertion) with an existing credential via a WebAuthn-exposed authenticator, use auth():

import { authDefaults, auth } from "..";

// optional:
var authOptions = authDefaults({
    // ..options..
});

var authResult = await auth(authOptions);

auth() returns a promise that will resolve to an object (authResult above) if successful. Otherwise, it will be rejected (await will throw).

Auth Configuration

To configure the authentication options, but include all the defaults for anything not being overridden, use authDefaults(..).

Typical auth() configuration options:

See authDefaults() function signature for more options.

Auth Result

auth() returns a promise that's fulfilled (success or rejection) once the user completes or cancels a credential (aka "passkey") authentication with their device's authenticator.

If auth() completes completes successfully, the return value (authResult above) will be an object that includes request and response properties:

Verifying an authentication response

To verify an authentication response (from auth()), use verifyAuthResponse():

import { verifyAuthResponse, } from "..";

var publicKey = ... // aka, regResult.response.publicKey

var verified = await verifyAuthResponse(
    authResult.response,
    publicKey
);

verifyAuthResponse() returns a promise that resolves to true if verification was successful. false indicates everything was well-formed, but the signature verification failed for some other reason. Otherwise, the promise is rejected (await will throw) if something was malformed/unexpected.

You will need to have preserved regResult.response.publicKey (and likely regResult.response.credentialID) from the original register() call for a credential -- either locally in e.g. LocalStorage or remotely on a server -- and later restore that to pass in on subsequent authentication and verification attempts; registration and authentication will not typically happen in the same page instance (where regResult would still be present).

Further, if you used packPublicKeyJSON() on the original publicKey value to store/transmit it, you'll need to use unpackPublicKeyJSON() before passing it to verifyAuthResponse():

import { verifyAuthResponse, unpackPublicKeyJSON } from "..";

var packedPublicKey = ... // result from previous packPublicKeyJSON()

var verified = await verifyAuthResponse(
    authResult.response,
    unpackPublicKeyJSON(packedPublicKey)
);

Re-building dist/*

If you need to rebuild the dist/* files for any reason, run:

# only needed one time
npm install

npm run build:all

Tests

Since the library involves non-automatable behaviors (requiring user intervention in browser), an automated unit-test suite is not included. Instead, a simple interactive browser test page is provided.

Visit https://mylofi.github.io/webauthn-local-client/, and follow instructions in-page from there to perform the interactive tests.

Note: You will either need a device with a built-in authenticator (i.e., Touch-ID, Face-ID, etc), or you can use Chrome DevTools to setup a virtual authenticator, or similar in Safari, or this Firefox add-on. For the virtual authenticator approach, it's recommended you use "ctap2", "internal", "resident keys", "large blob", and "user verification" for the settings. Also, since the tests do not save any generated credentials, you'll likely want to reset the authenticator by removing and re-adding it, before each page load; otherwise, you'll end up with lots of extraneous credentials while testing.

Run Locally

To instead run the tests locally, first make sure you've already run the build, then:

npm test

This will start a static file webserver (no server logic), serving the interactive test page from http://localhost:8080/; visit this page in your browser to perform tests.

By default, the test/test.js file imports the code from the src/* directly. However, to test against the dist/auto/* files (as included in the npm package), you can modify test/test.js, updating the /src in its import statement to /dist (see the import-map in test/index.html for more details).

License

License

All code and documentation are (c) 2024 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.