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 native ex_local_auth src lib.rs
Raw

native/ex_local_auth/src/lib.rs

use rustler::{Atom, Encoder, Env, Error, NifResult, Term};
mod atoms {
rustler::atoms! {
ok,
error,
available,
// Policies
biometrics,
biometrics_or_watch,
device_owner,
watch,
// Biometry types
none,
touch_id,
face_id,
optic_id,
// Error atoms
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,
not_interactive,
biometry_disconnected,
unknown,
// Options
policy,
cancel_title,
fallback_title,
}
}
// ── macOS Implementation ────────────────────────────────────────────
//
// LocalAuthentication.framework is macOS-only (and iOS, but we target
// macOS for Elixir NIF usage). The entire ObjC interaction is gated
// behind cfg(target_os = "macos").
//
// On non-macOS platforms, every NIF returns a sensible fallback or error.
#[cfg(target_os = "macos")]
mod macos {
use super::atoms;
use objc2::rc::Retained;
use objc2::runtime::Bool;
use objc2_foundation::{NSError, NSString};
use objc2_local_authentication::{LABiometryType, LAContext, LAPolicy};
use rustler::Atom;
use std::sync::mpsc;
// ── Policy Mapping ──────────────────────────────────────────────
/// Map an Elixir policy atom to the LAPolicy integer constant.
///
/// LAPolicy values (from Apple headers):
/// DeviceOwnerAuthenticationWithBiometrics = 1
/// DeviceOwnerAuthentication = 2
/// DeviceOwnerAuthenticationWithWatch = 3 (macOS 10.15+)
/// DeviceOwnerAuthenticationWithBiometricsOrWatch = 4 (macOS 10.15+)
pub fn policy_from_atom(atom: Atom) -> Result<LAPolicy, ()> {
if atom == atoms::biometrics() {
Ok(LAPolicy::DeviceOwnerAuthenticationWithBiometrics)
} else if atom == atoms::device_owner() {
Ok(LAPolicy::DeviceOwnerAuthentication)
} else if atom == atoms::watch() {
Ok(LAPolicy::DeviceOwnerAuthenticationWithWatch)
} else if atom == atoms::biometrics_or_watch() {
Ok(LAPolicy::DeviceOwnerAuthenticationWithBiometricsOrWatch)
} else {
Err(())
}
}
// ── Error Mapping ───────────────────────────────────────────────
/// Map an LAError code (NSError.code from the LAErrorDomain) to an
/// Elixir error atom.
///
/// LAError codes from <LocalAuthentication/LAError.h>:
/// -1 AuthenticationFailed
/// -2 UserCancel
/// -3 UserFallback
/// -4 SystemCancel
/// -5 PasscodeNotSet
/// -6 BiometryNotAvailable (was TouchIDNotAvailable)
/// -7 BiometryNotEnrolled (was TouchIDNotEnrolled)
/// -8 BiometryLockout (was TouchIDLockout)
/// -9 AppCancel
/// -10 InvalidContext
/// -11 WatchNotAvailable
/// -12 BiometryDisconnected
/// -1004 NotInteractive
pub fn error_atom_from_code(code: isize) -> Atom {
match code {
-1 => atoms::authentication_failed(),
-2 => atoms::user_cancel(),
-3 => atoms::user_fallback(),
-4 => atoms::system_cancel(),
-5 => atoms::passcode_not_set(),
-6 => atoms::biometry_not_available(),
-7 => atoms::biometry_not_enrolled(),
-8 => atoms::biometry_lockout(),
-9 => atoms::app_cancel(),
-10 => atoms::invalid_context(),
-11 => atoms::watch_not_available(),
-12 => atoms::biometry_disconnected(),
-1004 => atoms::not_interactive(),
_ => atoms::unknown(),
}
}
/// Extract error atom from an NSError pointer.
pub fn error_atom_from_nserror(err: &NSError) -> Atom {
error_atom_from_code(err.code())
}
// ── Capability Query ────────────────────────────────────────────
/// Check if a given policy can be evaluated on this device.
/// Returns Ok(:available) or Err(error_atom).
pub fn can_evaluate_impl(policy_atom: Atom) -> Result<Atom, Atom> {
let la_policy = policy_from_atom(policy_atom)
.map_err(|_| atoms::unknown())?;
unsafe {
let context = LAContext::new();
match context.canEvaluatePolicy_error(la_policy) {
Ok(()) => Ok(atoms::available()),
Err(err) => Err(error_atom_from_nserror(&err)),
}
}
}
// ── Biometry Type ───────────────────────────────────────────────
/// Get the type of biometric sensor available.
/// Must call canEvaluatePolicy first to populate the biometryType field.
pub fn biometry_type_impl() -> Atom {
unsafe {
let context = LAContext::new();
// Trigger population of biometryType
let _ = context.canEvaluatePolicy_error(
LAPolicy::DeviceOwnerAuthenticationWithBiometrics,
);
let bt = context.biometryType();
match bt {
LABiometryType::TouchID => atoms::touch_id(),
LABiometryType::FaceID => atoms::face_id(),
LABiometryType::OpticID => atoms::optic_id(),
_ => atoms::none(),
}
}
}
// ── Authentication ──────────────────────────────────────────────
/// Authenticate the device owner. This blocks the calling thread
/// (which should be a BEAM dirty I/O scheduler) until the user
/// completes or cancels the authentication dialog.
///
/// The LAContext.evaluatePolicy:localizedReason:reply: method takes
/// an ObjC block as its callback. We create a block2::RcBlock that
/// sends the result over an mpsc channel, then recv() on it.
pub fn authenticate_impl(
reason: &str,
policy_atom: Atom,
cancel: Option<&str>,
fallback: Option<&str>,
) -> Result<(), Atom> {
let la_policy = policy_from_atom(policy_atom)
.map_err(|_| atoms::unknown())?;
unsafe {
let context = LAContext::new();
// Optional UI customization
if let Some(ct) = cancel {
let ns = NSString::from_str(ct);
context.setLocalizedCancelTitle(Some(&ns));
}
if let Some(ft) = fallback {
let ns = NSString::from_str(ft);
context.setLocalizedFallbackTitle(Some(&ns));
}
let reason_ns = NSString::from_str(reason);
// Channel to bridge async ObjC block → sync Rust
let (tx, rx) = mpsc::channel::<(bool, Option<Retained<NSError>>)>();
let block = block2::RcBlock::new(move |success: Bool, err: *mut NSError| {
let error = if err.is_null() {
None
} else {
// Retain the error so it survives past the block invocation
Some(Retained::retain(err).unwrap())
};
let _ = tx.send((success.as_bool(), error));
});
context.evaluatePolicy_localizedReason_reply(
la_policy,
&reason_ns,
&block,
);
// Block until the system auth dialog completes
match rx.recv() {
Ok((true, _)) => Ok(()),
Ok((false, Some(err))) => Err(error_atom_from_nserror(&err)),
Ok((false, None)) => Err(atoms::authentication_failed()),
Err(_) => Err(atoms::unknown()),
}
}
}
// ── Context Invalidation ────────────────────────────────────────
/// Invalidate cached authentication state.
/// After this, the next authenticate() call will require fresh
/// biometric/passcode verification.
pub fn invalidate_impl() {
unsafe {
let context = LAContext::new();
context.invalidate();
}
}
}
// ── NIF Functions ───────────────────────────────────────────────────
//
// These are the Rustler NIF entry points called from Elixir.
// Each one delegates to the macOS module or returns a fallback.
/// Check if a given authentication policy can be evaluated.
///
/// Returns `{:ok, :available}` or `{:error, reason_atom}`.
#[rustler::nif(schedule = "DirtyIo")]
fn can_evaluate<'a>(env: Env<'a>, policy_atom: Atom) -> Term<'a> {
#[cfg(target_os = "macos")]
{
match macos::can_evaluate_impl(policy_atom) {
Ok(available) => (atoms::ok(), available).encode(env),
Err(reason) => (atoms::error(), reason).encode(env),
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = policy_atom;
(atoms::error(), atoms::biometry_not_available()).encode(env)
}
}
/// Get the biometry type available on this Mac.
///
/// Returns one of: `:none`, `:touch_id`, `:face_id`, `:optic_id`
#[rustler::nif(schedule = "DirtyIo")]
fn biometry_type() -> Atom {
#[cfg(target_os = "macos")]
{
macos::biometry_type_impl()
}
#[cfg(not(target_os = "macos"))]
{
atoms::none()
}
}
/// Authenticate the device owner.
///
/// Presents the system authentication dialog with the given reason string.
/// Blocks the dirty scheduler thread until completion.
///
/// Options (passed as keyword list from Elixir):
/// - `policy:` - `:biometrics`, `:device_owner`, `:watch`, `:biometrics_or_watch`
/// - `cancel_title:` - Custom cancel button text
/// - `fallback_title:` - Custom fallback button text (empty string hides it)
///
/// Returns `:ok` or `{:error, reason_atom}`.
#[rustler::nif(schedule = "DirtyIo")]
fn authenticate<'a>(
env: Env<'a>,
reason: String,
opts: Vec<(Atom, Term<'a>)>,
) -> Term<'a> {
// Parse options from the keyword list
let mut policy_atom = atoms::device_owner();
let mut cancel: Option<String> = None;
let mut fallback: Option<String> = None;
for (key, val) in &opts {
if *key == atoms::policy() {
if let Ok(a) = val.decode::<Atom>() {
policy_atom = a;
}
} else if *key == atoms::cancel_title() {
if let Ok(s) = val.decode::<String>() {
cancel = Some(s);
}
} else if *key == atoms::fallback_title() {
if let Ok(s) = val.decode::<String>() {
fallback = Some(s);
}
}
}
#[cfg(target_os = "macos")]
{
match macos::authenticate_impl(
&reason,
policy_atom,
cancel.as_deref(),
fallback.as_deref(),
) {
Ok(()) => atoms::ok().encode(env),
Err(reason_atom) => (atoms::error(), reason_atom).encode(env),
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = reason;
(atoms::error(), atoms::biometry_not_available()).encode(env)
}
}
/// Invalidate any cached authentication.
///
/// After calling this, the next `authenticate/2` call will require
/// fresh biometric or passcode verification.
#[rustler::nif]
fn invalidate() -> Atom {
#[cfg(target_os = "macos")]
{
macos::invalidate_impl();
}
atoms::ok()
}
rustler::init!("Elixir.ExLocalAuth");