use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TouchIdState {
Available,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptOutcome {
Authenticated,
Failed,
Cancelled,
SystemCancelled,
Unavailable(String),
}
pub trait TouchIdPrompt: Send + Sync {
fn state(&self) -> TouchIdState;
fn prompt(&self, reason: &str) -> PromptOutcome;
}
pub struct SessionAuthenticator {
prompt: Box<dyn TouchIdPrompt>,
idle: Mutex<Option<Instant>>,
idle_window: Duration,
}
pub const DEFAULT_IDLE_WINDOW: Duration = Duration::from_secs(15 * 60);
impl SessionAuthenticator {
pub fn new(prompt: Box<dyn TouchIdPrompt>) -> Self {
Self {
prompt,
idle: Mutex::new(None),
idle_window: DEFAULT_IDLE_WINDOW,
}
}
pub fn with_window(mut self, window: Duration) -> Self {
self.idle_window = window;
self
}
pub fn invalidate(&self) {
*self.idle.lock().unwrap() = None;
}
pub fn ensure_authenticated(&self, reason: &str) -> bool {
if self.prompt.state() != TouchIdState::Available {
return false;
}
let mut last = self.idle.lock().unwrap();
if let Some(t) = *last
&& t.elapsed() < self.idle_window
{
return true;
}
match self.prompt.prompt(reason) {
PromptOutcome::Authenticated => {
*last = Some(Instant::now());
true
}
_ => {
*last = None;
false
}
}
}
}
#[cfg(target_os = "macos")]
pub fn system_touch_id() -> Box<dyn TouchIdPrompt> {
Box::new(macos::MacTouchId)
}
#[cfg(not(target_os = "macos"))]
pub fn system_touch_id() -> Box<dyn TouchIdPrompt> {
Box::new(NoTouchId)
}
pub struct NoTouchId;
impl TouchIdPrompt for NoTouchId {
fn state(&self) -> TouchIdState {
TouchIdState::Unavailable
}
fn prompt(&self, _reason: &str) -> PromptOutcome {
PromptOutcome::Unavailable("Touch ID not available on this platform".into())
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
use objc2::rc::Retained;
use objc2::runtime::Bool;
use objc2_foundation::NSString;
use objc2_local_authentication::{
LAContext, LAPolicy, kLAErrorBiometryLockout, kLAErrorBiometryNotAvailable,
kLAErrorBiometryNotEnrolled, kLAErrorPasscodeNotSet, kLAErrorSystemCancel,
kLAErrorUserCancel, kLAErrorUserFallback,
};
pub struct MacTouchId;
impl MacTouchId {
fn fresh_context() -> Retained<LAContext> {
unsafe { LAContext::new() }
}
fn availability() -> TouchIdState {
let ctx = Self::fresh_context();
let policy = LAPolicy::DeviceOwnerAuthenticationWithBiometrics;
match unsafe { ctx.canEvaluatePolicy_error(policy) } {
Ok(()) => TouchIdState::Available,
Err(err) => {
let code = err.code() as i32;
if code == kLAErrorBiometryNotAvailable
|| code == kLAErrorBiometryNotEnrolled
|| code == kLAErrorBiometryLockout
|| code == kLAErrorPasscodeNotSet
{
TouchIdState::Unavailable
} else {
TouchIdState::Unavailable
}
}
}
}
}
impl TouchIdPrompt for MacTouchId {
fn state(&self) -> TouchIdState {
Self::availability()
}
fn prompt(&self, reason: &str) -> PromptOutcome {
let ctx = Self::fresh_context();
let policy = LAPolicy::DeviceOwnerAuthenticationWithBiometrics;
if let Err(e) = unsafe { ctx.canEvaluatePolicy_error(policy) } {
return PromptOutcome::Unavailable(format!(
"biometrics unavailable: code {}",
e.code()
));
}
let reason_ns = NSString::from_str(reason);
let (tx, rx) = std::sync::mpsc::channel::<(bool, Option<i32>)>();
let tx = Mutex::new(Some(tx));
let block =
block2::RcBlock::new(move |ok: Bool, err: *mut objc2_foundation::NSError| {
let code = if err.is_null() {
None
} else {
Some(unsafe { (*err).code() as i32 })
};
if let Some(tx) = tx.lock().unwrap().take() {
let _ = tx.send((ok.as_bool(), code));
}
});
let dyn_block: &block2::DynBlock<dyn Fn(Bool, *mut objc2_foundation::NSError)> = █
unsafe { ctx.evaluatePolicy_localizedReason_reply(policy, &reason_ns, dyn_block) }
match rx.recv_timeout(Duration::from_secs(120)) {
Ok((true, _)) => PromptOutcome::Authenticated,
Ok((false, Some(code))) => {
if code == kLAErrorUserCancel || code == kLAErrorUserFallback {
PromptOutcome::Cancelled
} else if code == kLAErrorSystemCancel {
PromptOutcome::SystemCancelled
} else {
PromptOutcome::Failed
}
}
Ok((false, None)) => PromptOutcome::Failed,
Err(_) => PromptOutcome::Unavailable("prompt timed out".into()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakePrompt {
state: TouchIdState,
outcomes: Mutex<Vec<PromptOutcome>>,
}
impl TouchIdPrompt for FakePrompt {
fn state(&self) -> TouchIdState {
self.state
}
fn prompt(&self, _reason: &str) -> PromptOutcome {
self.outcomes.lock().unwrap().remove(0)
}
}
fn auth(state: TouchIdState, outcomes: Vec<PromptOutcome>) -> SessionAuthenticator {
SessionAuthenticator::new(Box::new(FakePrompt {
state,
outcomes: Mutex::new(outcomes),
}))
}
#[test]
fn unavailable_fails_closed() {
let a = auth(TouchIdState::Unavailable, vec![]);
assert!(!a.ensure_authenticated("test"), "unavailable must deny");
}
#[test]
fn success_caches_within_window() {
let a = auth(
TouchIdState::Available,
vec![PromptOutcome::Authenticated, PromptOutcome::Failed],
);
assert!(a.ensure_authenticated("test"));
assert!(a.ensure_authenticated("test"));
}
#[test]
fn failure_does_not_cache() {
let a = auth(
TouchIdState::Available,
vec![PromptOutcome::Cancelled, PromptOutcome::Authenticated],
);
assert!(!a.ensure_authenticated("test"));
assert!(a.ensure_authenticated("test"));
}
#[test]
fn invalidate_forces_reprompt() {
let a = auth(
TouchIdState::Available,
vec![PromptOutcome::Authenticated, PromptOutcome::Authenticated],
);
assert!(a.ensure_authenticated("test"));
a.invalidate();
assert!(a.ensure_authenticated("test"));
}
}