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