Packages

Elixir NIF wrapper for webauthn-rs

Current section

Files

Jump to
ex_webauthn lib ex_webauthn.ex
Raw

lib/ex_webauthn.ex

defmodule ExWebauthn do
@moduledoc """
Elixir wrapper for webauthn-rs via NIF.
## Setup
Add to your supervision tree:
children = [
{ExWebauthn, rp_id: "example.com", rp_origin: "https://example.com"},
MyAppWeb.Endpoint
]
Or configure via application config:
# config/config.exs
config :ex_webauthn,
rp_id: "localhost",
rp_origin: "http://localhost:4000"
# config/prod.exs
config :ex_webauthn,
rp_id: "example.com",
rp_origin: "https://example.com"
Then add to your supervision tree without options:
children = [ExWebauthn]
## Usage
### Registration flow
# 1. Start registration — send challenge_options to the browser
{:ok, challenge_options, registration_state} =
ExWebauthn.start_registration(
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
"user@example.com",
"Jane Doe"
)
# 2. Store registration_state server-side (DB, session, ETS)
# 3. Send challenge_options as JSON to the browser
# 4. Browser calls navigator.credentials.create() and sends response back
# 5. Finish registration — verify the browser response
{:ok, credential} = ExWebauthn.finish_registration(registration_state, client_response)
# 6. Store credential in your DB for this user
### Authentication flow
# 1. Fetch stored credentials for the user from your DB
{:ok, challenge_options, authentication_state} =
ExWebauthn.start_authentication(stored_credentials)
# 2. Store authentication_state server-side
# 3. Send challenge_options to the browser
# 4. Browser calls navigator.credentials.get() and sends response back
# 5. Finish authentication
{:ok, auth_result} = ExWebauthn.finish_authentication(authentication_state, client_response)
### Discoverable credentials (usernameless login)
{:ok, challenge_options, authentication_state} =
ExWebauthn.start_authentication([])
"""
use GenServer
alias ExWebauthn.Native
@type error :: {:error, Native.error_kind(), String.t()}
@type challenge_options :: map()
@type credential :: map()
@type auth_result :: map()
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker
}
end
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(opts) do
config = Keyword.merge(Application.get_all_env(:ex_webauthn), opts)
rp_id = Keyword.fetch!(config, :rp_id)
rp_origin = Keyword.fetch!(config, :rp_origin)
case Native.init(rp_id, rp_origin) do
{:ok, {}} ->
{:ok, %{rp_id: rp_id, rp_origin: rp_origin}}
{:error, {error_kind, error_message}} ->
{:stop, {error_kind, error_message}}
end
end
@doc """
Reload config at runtime. Reads from app env or accepts overrides.
## Examples
ExWebauthn.reload(rp_id: "new.example.com", rp_origin: "https://new.example.com")
#=> :ok
"""
@spec reload(keyword()) :: :ok | error()
def reload(opts \\ []) do
GenServer.call(__MODULE__, {:reload, opts})
end
@impl true
def handle_call({:reload, opts}, _from, state) do
config = Keyword.merge(Application.get_all_env(:ex_webauthn), opts)
rp_id = Keyword.fetch!(config, :rp_id)
rp_origin = Keyword.fetch!(config, :rp_origin)
case Native.init(rp_id, rp_origin) do
{:ok, {}} ->
{:reply, :ok, %{rp_id: rp_id, rp_origin: rp_origin}}
{:error, {error_kind, error_message}} ->
{:reply, {:error, error_kind, error_message}, state}
end
end
@doc """
Start a passkey registration ceremony.
Returns challenge options to send to the browser and an opaque registration
state to store server-side.
## Parameters
* `user_unique_id` - UUID identifying the user
* `user_name` - human-readable account name (e.g. email)
* `user_display_name` - friendly display name
* `exclude_credentials` - list of existing credentials to prevent re-registration
## Examples
{:ok, challenge_options, registration_state} =
ExWebauthn.start_registration(
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
"user@example.com",
"Jane Doe"
)
With exclude credentials:
{:ok, challenge_options, registration_state} =
ExWebauthn.start_registration(
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
"user@example.com",
"Jane Doe",
existing_credentials
)
"""
@spec start_registration(
user_unique_id :: String.t(),
user_name :: String.t(),
user_display_name :: String.t(),
exclude_credentials :: [map()]
) :: {:ok, challenge_options(), registration_state :: String.t()} | error()
def start_registration(user_unique_id, user_name, user_display_name, exclude_credentials \\ []) do
exclude_credentials_json = Jason.encode!(exclude_credentials)
case Native.start_registration(
user_unique_id,
user_name,
user_display_name,
exclude_credentials_json
) do
{:ok, {creation_challenge_json, registration_state_json}} ->
{:ok, Jason.decode!(creation_challenge_json), registration_state_json}
{:error, {error_kind, error_message}} ->
{:error, error_kind, error_message}
end
end
@doc """
Complete a passkey registration ceremony.
Verifies the browser's response against the registration state from
`start_registration/4`. Returns the credential to store in your database.
## Parameters
* `registration_state` - opaque state from `start_registration/4`
* `client_response` - the browser's `PublicKeyCredential` response as a map
## Examples
{:ok, credential} =
ExWebauthn.finish_registration(registration_state, client_response)
"""
@spec finish_registration(registration_state :: String.t(), client_response :: map()) ::
{:ok, credential()} | error()
def finish_registration(registration_state, client_response) do
client_response_json = Jason.encode!(client_response)
case Native.finish_registration(registration_state, client_response_json) do
{:ok, credential_json} -> {:ok, Jason.decode!(credential_json)}
{:error, {error_kind, error_message}} -> {:error, error_kind, error_message}
end
end
@doc """
Start a passkey authentication ceremony.
Pass the user's stored credentials, or an empty list for discoverable
credential (usernameless) authentication.
## Parameters
* `credentials` - list of stored credentials for the user, or `[]` for discoverable flow
## Examples
# With known user credentials
{:ok, challenge_options, authentication_state} =
ExWebauthn.start_authentication(user_credentials)
# Discoverable / usernameless login
{:ok, challenge_options, authentication_state} =
ExWebauthn.start_authentication([])
"""
@spec start_authentication(credentials :: [map()]) ::
{:ok, challenge_options(), authentication_state :: String.t()} | error()
def start_authentication(credentials) do
credentials_json = Jason.encode!(credentials)
case Native.start_authentication(credentials_json) do
{:ok, {request_challenge_json, authentication_state_json}} ->
{:ok, Jason.decode!(request_challenge_json), authentication_state_json}
{:error, {error_kind, error_message}} ->
{:error, error_kind, error_message}
end
end
@doc """
Complete a passkey authentication ceremony.
Verifies the browser's response against the authentication state from
`start_authentication/1`. Returns the authentication result which may
contain updated credential counter info.
## Parameters
* `authentication_state` - opaque state from `start_authentication/1`
* `client_response` - the browser's `PublicKeyCredential` response as a map
## Examples
{:ok, auth_result} =
ExWebauthn.finish_authentication(authentication_state, client_response)
"""
@spec finish_authentication(authentication_state :: String.t(), client_response :: map()) ::
{:ok, auth_result()} | error()
def finish_authentication(authentication_state, client_response) do
client_response_json = Jason.encode!(client_response)
case Native.finish_authentication(authentication_state, client_response_json) do
{:ok, authentication_result_json} -> {:ok, Jason.decode!(authentication_result_json)}
{:error, {error_kind, error_message}} -> {:error, error_kind, error_message}
end
end
end