README.md

# ExLocalAuth

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

## Installation

Add to your `mix.exs`:

```elixir
{:ex_local_auth, "~> 0.1.0"}
```

Requires:

- macOS (Touch ID sensor or paired Apple Watch for biometric auth)
- Rust toolchain (for compiling the NIF)
- Elixir 1.15+

## Quick Start

```elixir
# 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:

- `policy:` — one of the policy atoms above (default: `:device_owner`)
- `cancel_title:` — custom text for the Cancel button
- `fallback_title:` — custom text for the fallback button (set to `""` to hide it)

## 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

```elixir
# 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](https://github.com/rusterlium/rustler) and
uses [objc2-local-authentication](https://crates.io/crates/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:

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

## License

MIT