Skip to content

Commit

Permalink
Add passkey autofill (#59)
Browse files Browse the repository at this point in the history
* Added checkConditionalUIAvailable on authentication_hook to enable passkey autofill

* Remove debug code and added autocomplete="username webauthn" on aunthentication_live.html.heex

* Added abort signal on registration hook to prevent error "A request is already pending" when sign up

* Workaround to fix Conditional UI on iOS/Safari when user cancel the action

* bump minor version to 0.7.0

---------

Co-authored-by: Owen Bickford <owen@owencode.com>
  • Loading branch information
Daniel Pinheiro and type1fool authored Dec 2, 2023
1 parent 3cf61c3 commit de2f439
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 7 deletions.
11 changes: 9 additions & 2 deletions lib/webauthn_components/authentication_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,17 @@ defmodule WebauthnComponents.AuthenticationComponent do
{:ok, assign(socket, assigns)}
end

def handle_event("authenticate", _params, socket) do
def handle_event("authenticate", params, socket) do
%{assigns: assigns, endpoint: endpoint} = socket
%{id: id} = assigns

supports_passkey_autofill = Map.has_key?(params, "supports_passkey_autofill")

event =
if supports_passkey_autofill,
do: "authentication-challenge-with-conditional-ui",
else: "authentication-challenge"

challenge =
Wax.new_authentication_challenge(
origin: endpoint.url,
Expand All @@ -149,7 +156,7 @@ defmodule WebauthnComponents.AuthenticationComponent do
:noreply,
socket
|> assign(:challenge, challenge)
|> push_event("authentication-challenge", challenge_data)
|> push_event(event, challenge_data)
}
end

Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule WebauthnComponents.MixProject do
# Don't forget to change the version in `package.json`
@name "WebauthnComponents"
@source_url "https://github.com/liveshowy/webauthn_components"
@version "0.6.6"
@version "0.7.0"

def project do
[
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "webauthn_components",
"version": "0.6.6",
"version": "0.7.0",
"main": "./priv/static/main.js",
"repository": {},
"files": [
Expand Down
42 changes: 42 additions & 0 deletions priv/static/abort_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class BaseAbortControllerService {
/**
* 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) {
const abortError = new Error(
"Cancelling existing WebAuthn API call for new one"
);
abortError.name = "AbortError";
this.controller.abort(abortError);
}

const newController = new AbortController();

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;
}
}
}

/**
* A service singleton to help ensure that only a single navigator.crendetials ceremony is active at a time.
*
*/
export const AbortControllerService = new BaseAbortControllerService();
39 changes: 37 additions & 2 deletions priv/static/authentication_hook.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
import { base64ToArray, arrayBufferToBase64, handleError } from "./utils";
import { browserSupportsPasskeyAutofill } from "./browser_supports_passkey_autofill";
import { AbortControllerService } from "./abort_controller";

export const AuthenticationHook = {
mounted() {
console.info(`AuthenticationHook mounted`);

this.checkConditionalUIAvailable(this);

this.handleEvent("authentication-challenge", (event) =>
this.handlePasskeyAuthentication(event, this)
this.handlePasskeyAuthentication(event, this, "optional")
);

this.handleEvent("authentication-challenge-with-conditional-ui", (event) =>
this.handlePasskeyAuthentication(event, this, "conditional")
);
},

async handlePasskeyAuthentication(event, context) {
async checkConditionalUIAvailable(context) {
if (!(await browserSupportsPasskeyAutofill())) {
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" as the only or last value in its `autocomplete` attribute was detected'
);
}

context.pushEventTo(context.el, "authenticate", {
supports_passkey_autofill: true,
});
},

async handlePasskeyAuthentication(event, context, mediation) {
try {
const { challenge, timeout, rpId, allowCredentials, userVerification } =
event;
Expand All @@ -25,6 +55,8 @@ export const AuthenticationHook = {
};
const credential = await navigator.credentials.get({
publicKey,
signal: AbortControllerService.createNewAbortSignal(),
mediation: mediation,
});
const { rawId, response, type } = credential;
const { clientDataJSON, authenticatorData, signature, userHandle } =
Expand All @@ -44,6 +76,9 @@ export const AuthenticationHook = {
userHandle64,
});
} catch (error) {
if (error.toString().includes("NotAllowedError:")) {
AbortControllerService.cancelCeremony();
}
console.error(error);
handleError(error, context);
}
Expand Down
9 changes: 9 additions & 0 deletions priv/static/browser_supports_passkey_autofill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function browserSupportsPasskeyAutofill() {
const globalPublicKeyCredential = window.PublicKeyCredential;

if (globalPublicKeyCredential.isConditionalMediationAvailable === undefined) {
return Promise.resolve(false);
}

return globalPublicKeyCredential.isConditionalMediationAvailable();
}
5 changes: 5 additions & 0 deletions priv/static/registration_hook.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { base64ToArray, arrayBufferToBase64, handleError } from "./utils";
import { AbortControllerService } from "./abort_controller";

export const RegistrationHook = {
mounted() {
Expand Down Expand Up @@ -43,6 +44,7 @@ export const RegistrationHook = {

const credential = await navigator.credentials.create({
publicKey,
signal: AbortControllerService.createNewAbortSignal(),
});

const { rawId, response, type } = credential;
Expand All @@ -58,6 +60,9 @@ export const RegistrationHook = {
type,
});
} catch (error) {
if (error.toString().includes("NotAllowedError:")) {
AbortControllerService.cancelCeremony();
}
console.error(error);
handleError(error, context);
}
Expand Down
2 changes: 1 addition & 1 deletion templates/live_views/authentication_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
>
<p>Create a <strong>new</strong> account:</p>

<.input type="email" field={form[:email]} label="Email" phx-debounce="250" />
<.input type="email" field={form[:email]} label="Email" phx-debounce="250" autocomplete="username webauthn" />
<.live_component
disabled={@form.source.valid? == false}
module={RegistrationComponent}
Expand Down

0 comments on commit de2f439

Please sign in to comment.