ExLocalAuth

Elixir bindings to macOS LocalAuthentication.framework — Touch ID, Apple Watch, and device passcode authentication via Rustler NIFs.

Installation

Add to your mix.exs:

{:ex_local_auth, "~> 0.1.0"}

Requires:

Quick Start

# Check if Touch ID is available
{:ok, :available} = ExLocalAuth.can_evaluate?(:biometrics)

# What kind of biometric sensor?
:touch_id = ExLocalAuth.biometry_type()

# Authenticate — shows the system Touch ID / password dialog
case ExLocalAuth.authenticate("Access 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, use passcode")

  {:error, reason} ->
    IO.puts("Failed: #{reason}")
end

Policies

Atom What it accepts
:biometrics Touch ID only
:biometrics_or_watch Touch ID or Apple Watch
:device_owner Touch ID, Apple Watch, or device passcode
:watch Apple Watch only

Options

authenticate/2 accepts a keyword list:

Error Atoms

Atom Meaning
:user_cancel User tapped Cancel
:user_fallback User tapped the fallback button
:system_cancel System cancelled (e.g. app went to background)
:passcode_not_set No passcode configured on device
:biometry_not_available No biometric hardware
:biometry_not_enrolled Biometric hardware exists but no fingerprints enrolled
:biometry_lockout Too many failed attempts
:app_cancel App programmatically cancelled
:invalid_context LAContext was previously invalidated
:watch_not_available No paired Apple Watch
:authentication_failed User failed to authenticate
:biometry_disconnected Biometric sensor disconnected

Convenience Functions

# Boolean checks
ExLocalAuth.touch_id_available?()
ExLocalAuth.device_owner_auth_available?()

# Policy-specific wrappers
ExLocalAuth.authenticate_biometric("Unlock with Touch ID")
ExLocalAuth.authenticate_device_owner("Verify your identity")

How It Works

The NIF is built with Rustler and uses objc2-local-authentication for type-safe Rust bindings to Apple's ObjC framework.

The authenticate/2 call runs on a BEAM dirty I/O scheduler thread. It creates a fresh LAContext, configures it, then calls evaluatePolicy:localizedReason:reply: with an ObjC block. The block sends the result over a Rust mpsc channel, and the NIF blocks until the user completes or cancels the system dialog.

Each authenticate call creates a new LAContext to avoid the iOS/macOS quirk where reusing a previously-successful context skips re-verification.

Integration with ExKeychain

For maximum security, combine LocalAuthentication with Keychain access control lists:

# Gate credential access behind Touch ID
:ok = ExLocalAuth.authenticate("Access your SSH key")
{:ok, key} = ExKeychain.get("ssh-keys", "id_ed25519")

License

MIT