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.
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.
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.
-
USING A WEB BUNDLER? (Astro, Vite, Webpack, etc) Use the
dist/bundlers/*
files and see Bundler Deployment for instructions. -
Otherwise, use the
dist/auto/*
files and see Non-Bundler Deployment for instructions.
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
}
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).
To configure the registration options, but include all the defaults for anything not being overridden, use regDefaults(..)
.
Typical register()
configuration options:
-
relyingPartyName
(string): the common name of your application (that a user will recognize), e.g. "Cool Notes App".Note:
relyingPartyID
(string) is also available, defaulting to the origin hostname of your web application (e.g.,hostname.tld
); unless you have an specific reason, you should generally leave that as default. -
user
(object): specifies the user's identity (as it's defined in your application), including up to these 3 sub properties:-
name
(string): the user's name -
displayName
(string): a displayable version of the user's name (typically the same asname
, but can be a shorter abbreviation/nickname ifname
is too long) -
id
(Uint8Array): any application-defined value (string, integer, etc), but must be represented as aUint8Array
byte array.Note: This value can be anything your application needs for its normal operation, but it can never be updated for a specific credential after registration; the user will have to
register()
a new credential if your application ever needs to change this value. Also, be careful not to use a value with too many bytes, or some authenticators may reject it. Generally, 30-40 bytes is safe (and more than sufficient for most common use-cases), but you likely will not be able to use hundreds or thousands of bytes for this value. This is not a secret user-data storage location!
-
-
excludeCredentials
(array): Defaults to an empty array, which allows subsequentregister()
calls on the same authenticator, with the sameuser.id
value, to overwrite a credential (regenerate its internal keypair).This is generally only useful in cases where a credential keypair needs to be reset (such as losing the originally returned public-key). As such,
excludeCredentials
should only be left to its default empty array if there are no known credentials for the user, or the UX has clearly indicated to the user that a reset is being performed.If you pass a non-empty array (object values, e.g.
{ type: "public-key", id: ... }
, whereid
is the credential ID), and theuser.id
passed in matches the internally storeduserID
(akauserHandle
) of any of those credentials, theregister()
call will throw an exception (asynchronously in the promise). -
signal
(AbortSignal): anAbortController.signal
instance to cancel the registration request
See regDefaults()
function signature for more options.
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:
-
The
request
property includes all relevant configurations that were applied to the registration request, and is provided mostly for debugging purposes. -
The
response
property will include the data needed to use (and subsequently identify) the newly registered credential.The most important parts are
credentialID
(base64 padded encoding string) andpublicKey
, with various pieces of information about the keypair (COSE ID for the algorithm, the OID of the algorithm in hex-string format, and thespki
andraw
representations of the public-key) generated for the credential; this info is used for verifying the signature on subsequentauth()
requests.The
publicKey
object includes byte-arrays (Uint8Array
), which are not as conveniently serialized to/from JSON. Two helper methods are provided to make this easy:packPublicKeyJSON()
(to store/transmit in base64 string form) andunpackPublicKeyJSON()
(to restore from base64 string form).
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.
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).
To configure the authentication options, but include all the defaults for anything not being overridden, use authDefaults(..)
.
Typical auth()
configuration options:
-
allowCredentials
(array): Defaults to an empty array, which allows the user to select any available discoverable credential (aka, "resident key").Note: If you use the "discoverable credential" approach, and don't preserve the
credentialID
andpublicKey
from an initialregister()
call, you won't be able to verify any authorization responses (verifyAuthResponse()
), since that requires the public key (only returned fromregister()
).If you pass a non-empty array (object values, e.g.
{ type: "public-key", id: ... }
whereid
is the credential ID), the browser will present a narrowed list of credentials for the user to select from. -
mediation
(string): Defaults to"optional"
, but can also be set to"conditional"
to trigger passkey autofill (aka "Conditional Mediation"), if the browser/device supports it (seesupportsConditionalMediation
).Note: If conditional-mediation is supported and
mediation: "conditional"
is specified, the promise result ofauth()
will remain pending until the user clicks into a suitable<input autocomplete="username webauthn">
element in the page, and then selects their credential from the autofill prompt. Make sure you provide the user such a form element and suitable UX/flow to explain to them what to do. Also, such a request should likely be specified as cancelable (viasignal
) in case the user does not want to use autofill. -
challenge
(Uint8Array): Defaults to 20 bytes of generated randomness, but can be provided manually if you have another source of suitable information to use for a challenge. The returned result will include a signature (response.signature
) that was generated against this challenge (along with other request info), helping to strengthen the security of the system (i.e., preventing "replay attacks"). -
signal
(AbortSignal): anAbortController.signal
instance to cancel the authentication request.For certain UX flows, such as switching from the conditional-mediation to another authentication approach, you will need to cancel (via
signal
) a previous call toauth()
before invoking anauth()
call with different options. But callingabort()
causes that pendingauth()
to throw an exception. To suppress this exception when resetting, pass theresetAbortReason
value:import { resetAbortReason, authDefaults, auth } from ".."; var cancelToken = new AbortController(); var authResult = await auth({ /* .. */ , signal: cancelToken.signal }); // elsewhere: cancelToken.abort(resetAbortReason); cancelToken = new AbortController(); var newAuthResult = await auth({ /* .. */ , signal: cancelToken.signal }); // ..
See authDefaults()
function signature for more options.
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:
-
The
request
property includes all relevant configurations that were applied to the authentication request, and is provided mostly for debugging purposes. -
The
response
property will include information about the credential used, as well as a signature to verify the authentication response.The most important parts of
response
are:-
credentialID
: will match thecredentialID
from the originatingregister()
call -
userID
: will match theuser.id
configuration from the originatingregister()
callNote: For security reasons, authenticators only return this value when the type of authentication performed was interactive (user was present and affirmatively presented their passkey). The default
userVerification
configuration value (inauthDefaults()
) is"required"
, which ensures the authentication will satisfy that requirement and thus returnuserID
. Moreover, two additionalresponse
properties (userPresence
,userVerification
) will betrue
if those conditions were indeed met. -
signature
: used viaverifyAuthResponse(..)
-- along with the public key from the originalregister()
call for that credential -- to verify the signature against therequest.challenge
(and other request settings/info).
-
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)
);
If you need to rebuild the dist/*
files for any reason, run:
# only needed one time
npm install
npm run build:all
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.
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).
All code and documentation are (c) 2024 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.