diff --git a/.gitignore b/.gitignore index 466a398..56d3c63 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ webauthn_components-*.tar # Temporary files, for example, from tests. /tmp/ + +# Lexical's working directory +/.lexical/ \ No newline at end of file diff --git a/lib/webauthn_components/authentication_component.ex b/lib/webauthn_components/authentication_component.ex index 2c7d539..ac54616 100644 --- a/lib/webauthn_components/authentication_component.ex +++ b/lib/webauthn_components/authentication_component.ex @@ -23,7 +23,7 @@ defmodule WebauthnComponents.AuthenticationComponent do ## Assigns - - `@challenge`: (Internal) A `Wax.Challenge` struct created by the component, used to request an existing credential in the client. + - `@challenge` (Internal) A `Wax.Challenge` struct created by the component, used to request an existing credential in the client. - `@display_text` (Optional) The text displayed inside the button. Defaults to "Sign In". - `@show_icon?` (Optional) Controls visibility of the key icon. Defaults to `true`. - `@class` (Optional) CSS classes for overriding the default button style. @@ -31,6 +31,14 @@ defmodule WebauthnComponents.AuthenticationComponent do - `@id` (Optional) An HTML element ID. - `@skip_conditional_ui_check` (Optional) Set to `true` to skip the conditional UI check for Passkey autofill. Defaults to `false`. + If the authenticator is not supporting resident keys you have to provide a list of `%WebauthnCredentials{}` either by using: + - `@user` (Optional) A user identifier like e-mail or user name as a string + - `@retrieve_credentials_function` (Optional) A function of the type `@spec retrieve_credentials_for(binary) :: [%WebauthnCredential{}]` which is supposed to get the IDs and the public keys for a given user + + or + + - `@allow_credentials` (Optional) A list of `%WebauthnCredentials{}` + ## Events - `"authenticate"`: Triggered when a user clicks the `authenticate` button. @@ -63,6 +71,9 @@ defmodule WebauthnComponents.AuthenticationComponent do |> assign_new(:display_text, fn -> "Sign In" end) |> assign_new(:show_icon?, fn -> true end) |> assign_new(:relying_party, fn -> nil end) + |> assign_new(:allow_credentials, fn -> [] end) + |> assign_new(:user, fn -> nil end) + |> assign_new(:retrieve_credentials_function, fn -> nil end) |> assign_new(:skip_conditional_ui_check, fn -> false end) } end @@ -81,8 +92,8 @@ defmodule WebauthnComponents.AuthenticationComponent do disabled={@disabled} data-skip-conditional-ui-check={if @skip_conditional_ui_check, do: "true"} > - <.icon_key /> - <%= @display_text %> + <.icon_key /> + {@display_text} @@ -133,7 +144,13 @@ defmodule WebauthnComponents.AuthenticationComponent do def handle_event("authenticate", params, socket) do %{assigns: assigns, endpoint: endpoint} = socket - %{id: id} = assigns + + %{ + id: id, + allow_credentials: allow_credentials, + retrieve_credentials_function: retrieve_credentials_function, + user: user + } = assigns supports_passkey_autofill = Map.has_key?(params, "supports_passkey_autofill") @@ -142,18 +159,35 @@ defmodule WebauthnComponents.AuthenticationComponent do do: "authentication-challenge-with-conditional-ui", else: "authentication-challenge" + # If both, a function and a user are supplied, call that function to retrieve the credentials for + # the given user + # Otherwise: use the allow_credential list which might be preset instead + credentials = + if is_nil(retrieve_credentials_function) and is_nil(user) do + allow_credentials + else + retrieve_credentials_function.(user) + end + challenge = Wax.new_authentication_challenge( + # WAX_ expects a list of maps, we have been using structs to do the xfer: So convert back + allow_credentials: + for credential <- credentials do + {credential.id, credential.public_key} + end, origin: endpoint.url(), rp_id: :auto, user_verification: "preferred" ) + allow_credentials_ids = Enum.map(credentials, &Base.encode64(&1.id)) + challenge_data = %{ challenge: Base.encode64(challenge.bytes, padding: false), id: id, rpId: challenge.rp_id, - allowCredentials: challenge.allow_credentials, + allowCredentialsIDs: allow_credentials_ids, userVerification: challenge.user_verification } diff --git a/lib/webauthn_components/registration_component.ex b/lib/webauthn_components/registration_component.ex index 85956b2..d0e5650 100644 --- a/lib/webauthn_components/registration_component.ex +++ b/lib/webauthn_components/registration_component.ex @@ -1,6 +1,6 @@ defmodule WebauthnComponents.RegistrationComponent do @moduledoc """ - A LiveComponent for registering a new Passkey via the WebAuthn API. + A LiveComponent for registering a new Passkey via the WebAuthn API! > Registration = Sign Up @@ -123,8 +123,8 @@ defmodule WebauthnComponents.RegistrationComponent do title="Create a new account" disabled={@disabled} > - <.icon_key /> - <%= @display_text %> + <.icon_key /> + {@display_text} """ diff --git a/lib/webauthn_components/webauthn_credential.ex b/lib/webauthn_components/webauthn_credential.ex new file mode 100644 index 0000000..21861ae --- /dev/null +++ b/lib/webauthn_components/webauthn_credential.ex @@ -0,0 +1,31 @@ +defmodule WebauthnComponents.WebauthnCredential do + @moduledoc """ + Struct representing a credential to be used by the WebAuthn API. + """ + + @enforce_keys [:id, :public_key] + defstruct [:id, :public_key] + + @type t :: %__MODULE__{ + id: binary(), + public_key: String.t() + } + + defimpl Jason.Encoder, for: __MODULE__ do + def encode(struct, opts) do + map = Map.from_struct(struct) + + encoded_public_key = + for {k, v} <- map[:public_key], into: %{} do + if is_binary(v) do + {k, Base.encode64(v)} + else + {k, v} + end + end + + encoded_map = %{id: Base.encode64(map[:id]), public_key: encoded_public_key} + Jason.Encode.map(encoded_map, opts) + end + end +end diff --git a/priv/static/authentication_hook.js b/priv/static/authentication_hook.js index a68eb1c..40be072 100644 --- a/priv/static/authentication_hook.js +++ b/priv/static/authentication_hook.js @@ -43,9 +43,15 @@ export const AuthenticationHook = { async handlePasskeyAuthentication(event, context, mediation) { try { - const { challenge, timeout, rpId, allowCredentials, userVerification } = + const { challenge, timeout, rpId, allowCredentialsIDs, userVerification } = event; + // allowCredentialsIDs is an array of already base64 encoded IDs + allowCredentials = new Array(); + for (const id of allowCredentialsIDs) { + allowCredentials.push({ id: base64ToArray(id), type: 'public-key' }); + }; + const challengeArray = base64ToArray(challenge); const publicKey = { @@ -55,6 +61,9 @@ export const AuthenticationHook = { timeout, userVerification, }; + + console.log(publicKey); + const credential = await navigator.credentials.get({ publicKey, signal: AbortControllerService.createNewAbortSignal(), diff --git a/priv/static/registration_hook.js b/priv/static/registration_hook.js index e251e60..b06c18c 100644 --- a/priv/static/registration_hook.js +++ b/priv/static/registration_hook.js @@ -48,7 +48,8 @@ export const RegistrationHook = { const publicKey = { attestation, authenticatorSelection: { - authenticatorAttachment: "platform", + // authenticatorAttachment: "platform", + authenticatorAttachment: "all", residentKey: residentKey, requireResidentKey: requireResidentKey, },