Packages

Elixir bindings to macOS LocalAuthentication.framework. Touch ID, Face ID, and Apple Watch biometric authentication from Elixir.

Current section

Files

Jump to
ex_local_auth lib ex_local_auth.ex
Raw

lib/ex_local_auth.ex

defmodule ExLocalAuth do
@moduledoc """
Elixir bindings to macOS LocalAuthentication.framework.
Provides biometric authentication (Touch ID), Apple Watch unlock,
and device passcode verification. Uses [Rustler](https://github.com/rusterlium/rustler)
NIFs backed by [objc2-local-authentication](https://crates.io/crates/objc2-local-authentication).
## Authentication Policies
Different policies control what authentication methods are acceptable:
* `:biometrics` - Touch ID only. Fails if Touch ID is not available
or not enrolled.
* `:biometrics_or_watch` - Touch ID or Apple Watch. Useful for Macs
without Touch ID but paired with an Apple Watch.
* `:device_owner` - Touch ID, Apple Watch, or device passcode.
This is the most permissive and always succeeds if the user can
prove device ownership.
* `:watch` - Apple Watch only.
## Basic Usage
# Check if Touch ID is available
{:ok, :available} = ExLocalAuth.can_evaluate?(:biometrics)
# Authenticate with Touch ID
case ExLocalAuth.authenticate("Unlock your credentials", policy: :device_owner) do
:ok -> IO.puts("Authenticated!")
{:error, :user_cancel} -> IO.puts("User cancelled")
{:error, :biometry_lockout} -> IO.puts("Too many failed attempts")
{:error, reason} -> IO.puts("Failed: \#{reason}")
end
## Guarding Keychain Access
LocalAuthentication is commonly used to gate access to keychain items
that have access control lists requiring biometric verification.
The `ExKeychain` library can be combined with `ExLocalAuth` for this:
# First verify the user, then read the credential
:ok = ExLocalAuth.authenticate("Access your SSH key")
{:ok, key} = ExKeychain.get("ssh-keys", "id_ed25519")
## Platform Requirements
macOS only. Touch ID requires a Mac with a Touch ID sensor (MacBook Pro
2016+, MacBook Air 2018+, or any Mac with Apple Silicon). Apple Watch
unlock requires a paired Apple Watch running watchOS 6+.
"""
use Rustler,
otp_app: :ex_local_auth,
crate: "ex_local_auth"
@type policy :: :biometrics | :biometrics_or_watch | :device_owner | :watch
@type biometry_type :: :none | :touch_id | :face_id | :optic_id
@type auth_error ::
:user_cancel
| :user_fallback
| :system_cancel
| :passcode_not_set
| :biometry_not_available
| :biometry_not_enrolled
| :biometry_lockout
| :app_cancel
| :invalid_context
| :watch_not_available
| :authentication_failed
| :biometry_disconnected
| :not_interactive
| :unknown
# ── Raw NIFs ─────────────────────────────────────────────────────────
#
# These map directly to the Rust NIF function names.
@doc false
def can_evaluate(_policy),
do: :erlang.nif_error(:nif_not_loaded)
@doc false
def authenticate(_reason, _opts \\ []),
do: :erlang.nif_error(:nif_not_loaded)
# ── Public API ───────────────────────────────────────────────────────
@doc """
Check if a given authentication policy can be evaluated.
Returns `{:ok, :available}` if the policy can be used, or
`{:error, reason}` explaining why not.
## Examples
{:ok, :available} = ExLocalAuth.can_evaluate?(:biometrics)
{:error, :biometry_not_enrolled} = ExLocalAuth.can_evaluate?(:biometrics)
{:ok, :available} = ExLocalAuth.can_evaluate?(:device_owner)
"""
@spec can_evaluate?(policy()) :: {:ok, :available} | {:error, auth_error()}
def can_evaluate?(policy), do: can_evaluate(policy)
@doc """
Get the biometry type available on this Mac.
Returns the type of biometric sensor available:
* `:none` - No biometric sensor
* `:touch_id` - Touch ID (fingerprint)
* `:face_id` - Face ID (not currently on any Mac, but exists on iOS)
* `:optic_id` - Optic ID (Apple Vision Pro)
Note: `biometryType` is only populated after calling `can_evaluate?/1`.
This function handles that automatically.
"""
@spec biometry_type() :: biometry_type()
def biometry_type,
do: :erlang.nif_error(:nif_not_loaded)
@doc """
Invalidate any cached authentication.
After calling this, the next `authenticate/2` call will require
fresh biometric or passcode verification, even if the system would
normally cache the result for a short period.
Note: each LAContext instance is independent. This creates a new
context and invalidates it. If you need to reuse context state,
consider using the raw NIF layer directly.
"""
@spec invalidate() :: :ok
def invalidate,
do: :erlang.nif_error(:nif_not_loaded)
# ── Convenience Functions ────────────────────────────────────────────
@doc """
Check if Touch ID is available and enrolled on this Mac.
Returns `true` if the biometrics policy can be evaluated.
## Examples
if ExLocalAuth.touch_id_available?() do
:ok = ExLocalAuth.authenticate("Unlock", policy: :biometrics)
end
"""
@spec touch_id_available?() :: boolean()
def touch_id_available? do
case can_evaluate?(:biometrics) do
{:ok, :available} -> true
_ -> false
end
end
@doc """
Check if any form of device owner authentication is available.
This is the most permissive check — it succeeds if the device has
Touch ID, Apple Watch, or even just a passcode set.
## Examples
true = ExLocalAuth.device_owner_auth_available?()
"""
@spec device_owner_auth_available?() :: boolean()
def device_owner_auth_available? do
case can_evaluate?(:device_owner) do
{:ok, :available} -> true
_ -> false
end
end
@doc """
Authenticate with Touch ID only.
Convenience wrapper that sets `policy: :biometrics`.
Returns `:ok` or `{:error, reason}`.
"""
@spec authenticate_biometric(String.t(), keyword()) :: :ok | {:error, auth_error()}
def authenticate_biometric(reason, opts \\ []) do
authenticate(reason, Keyword.put(opts, :policy, :biometrics))
end
@doc """
Authenticate with any available method (Touch ID, Watch, or passcode).
Convenience wrapper that sets `policy: :device_owner`. This is the
default and most permissive policy.
Returns `:ok` or `{:error, reason}`.
"""
@spec authenticate_device_owner(String.t(), keyword()) :: :ok | {:error, auth_error()}
def authenticate_device_owner(reason, opts \\ []) do
authenticate(reason, Keyword.put(opts, :policy, :device_owner))
end
end