Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/biometric-macos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"biometric": minor:feat
---

Add macOS Touch ID support via LocalAuthentication framework.
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/api/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ tauri-plugin-opener = { path = "../../../plugins/opener", version = "2.5.3" }
tauri-plugin-shell = { path = "../../../plugins/shell", version = "2.3.5" }
tauri-plugin-store = { path = "../../../plugins/store", version = "2.4.2" }
tauri-plugin-upload = { path = "../../../plugins/upload", version = "2.3.0" }
tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.3.2" }

[dependencies.tauri]
workspace = true
Expand All @@ -63,7 +64,6 @@ tauri-plugin-window-state = { path = "../../../plugins/window-state", version =
[target."cfg(any(target_os = \"android\", target_os = \"ios\"))".dependencies]
tauri-plugin-barcode-scanner = { path = "../../../plugins/barcode-scanner/", version = "2.4.4" }
tauri-plugin-nfc = { path = "../../../plugins/nfc", version = "2.3.4" }
tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.3.2" }
tauri-plugin-geolocation = { path = "../../../plugins/geolocation/", version = "2.3.2" }
tauri-plugin-haptics = { path = "../../../plugins/haptics/", version = "2.3.2" }

Expand Down
2 changes: 2 additions & 0 deletions examples/api/src-tauri/capabilities/desktop.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"permissions": [
"cli:default",
"updater:default",
"biometric:allow-authenticate",
"biometric:allow-status",
"global-shortcut:allow-unregister",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister-all",
Expand Down
2 changes: 1 addition & 1 deletion examples/api/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_biometric::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
Expand All @@ -56,7 +57,6 @@ pub fn run() {
{
app.handle().plugin(tauri_plugin_barcode_scanner::init())?;
app.handle().plugin(tauri_plugin_nfc::init())?;
app.handle().plugin(tauri_plugin_biometric::init())?;
app.handle().plugin(tauri_plugin_geolocation::init())?;
app.handle().plugin(tauri_plugin_haptics::init())?;
}
Expand Down
2 changes: 1 addition & 1 deletion examples/api/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
component: Nfc,
icon: 'i-ph-nfc'
},
isMobile && {
{
label: 'Biometric',
component: Biometric,
icon: 'i-ph-scan'
Expand Down
10 changes: 8 additions & 2 deletions plugins/biometric/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "tauri-plugin-biometric"
version = "2.3.2"
description = "Prompt the user for biometric authentication on Android and iOS."
description = "Prompt the user for biometric authentication on Android, iOS, and macOS."
edition = { workspace = true }
authors = { workspace = true }
license = { workspace = true }
Expand All @@ -14,7 +14,7 @@ targets = ["x86_64-linux-android"]
[package.metadata.platforms.support]
windows = { level = "none", notes = "" }
linux = { level = "none", notes = "" }
macos = { level = "none", notes = "" }
macos = { level = "full", notes = "" }
android = { level = "full", notes = "" }
ios = { level = "full", notes = "" }

Expand All @@ -29,3 +29,9 @@ tauri = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
serde_repr = "0.1"

[target."cfg(target_os = \"macos\")".dependencies]
block2 = "0.6"
objc2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = ["NSError", "NSString"] }
objc2-local-authentication = { version = "0.3", default-features = false, features = ["LAContext", "LAError", "LABiometryType", "block2"] }
4 changes: 2 additions & 2 deletions plugins/biometric/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
![biometric](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/biometric/banner.png)

Prompt the user for biometric authentication on Android and iOS.
Prompt the user for biometric authentication on Android, iOS and macOS.

| Platform | Supported |
| -------- | --------- |
| Linux | x |
| Windows | x |
| macOS | x |
| macOS | |
| Android | ✓ |
| iOS | ✓ |

Expand Down
39 changes: 39 additions & 0 deletions plugins/biometric/src/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use tauri::{command, AppHandle, Runtime, State};

use crate::{models::*, Biometric, Result};

#[command]
pub(crate) async fn status<R: Runtime>(
_app: AppHandle<R>,
biometric: State<'_, Biometric<R>>,
) -> Result<Status> {
biometric.status()
}

#[command]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn authenticate<R: Runtime>(
_app: AppHandle<R>,
biometric: State<'_, Biometric<R>>,
reason: String,
allow_device_credential: Option<bool>,
cancel_title: Option<String>,
fallback_title: Option<String>,
title: Option<String>,
subtitle: Option<String>,
confirmation_required: Option<bool>,
) -> Result<()> {
let options = AuthOptions {
allow_device_credential: allow_device_credential.unwrap_or(false),
cancel_title,
fallback_title,
title,
subtitle,
confirmation_required,
};
biometric.authenticate(reason, options)
}
181 changes: 181 additions & 0 deletions plugins/biometric/src/desktop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};

use crate::models::*;

pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<Biometric<R>> {
Ok(Biometric(app.clone()))
}

/// Access to the biometric APIs.
pub struct Biometric<R: Runtime>(AppHandle<R>);

impl<R: Runtime> Biometric<R> {
pub fn status(&self) -> crate::Result<Status> {
#[cfg(target_os = "macos")]
{
macos::status()
}
#[cfg(not(target_os = "macos"))]
{
Ok(Status {
is_available: false,
biometry_type: BiometryType::None,
error: Some("Biometric authentication is not supported on this platform".into()),
error_code: Some("biometryNotAvailable".into()),
})
}
}

pub fn authenticate(&self, reason: String, options: AuthOptions) -> crate::Result<()> {
#[cfg(target_os = "macos")]
{
macos::authenticate(reason, options)
}
#[cfg(not(target_os = "macos"))]
{
let _ = (reason, options);
Err(crate::Error::Unavailable(
"Biometric authentication is not supported on this platform".into(),
))
}
}
}

#[cfg(target_os = "macos")]
mod macos {
use super::*;
use block2::RcBlock;
use objc2::runtime::Bool;
use objc2_foundation::{NSError, NSString};
use objc2_local_authentication::{LABiometryType, LAContext, LAPolicy};

pub fn status() -> crate::Result<Status> {
let context = unsafe { LAContext::new() };
let result = unsafe {
context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics)
};
let biometry_type = unsafe { context.biometryType() };

match result {
Ok(()) => Ok(Status {
is_available: true,
biometry_type: convert_biometry_type(biometry_type),
error: None,
error_code: None,
}),
Err(error) => {
let desc = error.localizedDescription().to_string();
let code = map_la_error_code(error.code());

Ok(Status {
is_available: false,
biometry_type: convert_biometry_type(biometry_type),
error: Some(desc),
error_code: Some(code),
})
}
}
}

pub fn authenticate(reason: String, options: AuthOptions) -> crate::Result<()> {
let context = unsafe { LAContext::new() };

// Pre-check: if biometry is unavailable and device credential fallback is disabled,
// return early with the error.
let can_evaluate = unsafe {
context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics)
};
if let Err(error) = can_evaluate {
if !options.allow_device_credential {
let desc = error.localizedDescription().to_string();
let code = map_la_error_code(error.code());
return Err(crate::Error::Unavailable(format!("[{code}] {desc}")));
}
}

// Set localized titles
if let Some(ref fallback_title) = options.fallback_title {
let title = NSString::from_str(fallback_title);
unsafe { context.setLocalizedFallbackTitle(Some(&title)) };
}
if options.allow_device_credential && matches!(options.fallback_title.as_deref(), Some(""))
{
unsafe { context.setLocalizedFallbackTitle(None) };
}
if let Some(ref cancel_title) = options.cancel_title {
let title = NSString::from_str(cancel_title);
unsafe { context.setLocalizedCancelTitle(Some(&title)) };
}

// Disable authentication reuse
unsafe { context.setTouchIDAuthenticationAllowableReuseDuration(0.0) };

let reason = NSString::from_str(&reason);
let policy = if options.allow_device_credential {
LAPolicy::DeviceOwnerAuthentication
} else {
LAPolicy::DeviceOwnerAuthenticationWithBiometrics
};

let (tx, rx) = std::sync::mpsc::channel();
let block = RcBlock::new(move |success: Bool, error: *mut NSError| {
if success.as_bool() {
tx.send(Ok(())).ok();
} else {
let err_msg = if !error.is_null() {
let e = unsafe { &*error };
let desc = e.localizedDescription().to_string();
let code = map_la_error_code(e.code());
format!("[{code}] {desc}")
} else {
"Authentication failed".to_string()
};
tx.send(Err(crate::Error::AuthenticationFailed(err_msg)))
.ok();
}
});

unsafe {
context.evaluatePolicy_localizedReason_reply(policy, &reason, &block);
}

rx.recv()
.map_err(|_| crate::Error::AuthenticationFailed("Channel closed".into()))?
}

fn convert_biometry_type(biometry_type: LABiometryType) -> BiometryType {
if biometry_type == LABiometryType::TouchID {
BiometryType::TouchID
} else if biometry_type == LABiometryType::FaceID {
BiometryType::FaceID
} else {
BiometryType::None
}
}

fn map_la_error_code(code: isize) -> String {
match code {
-1 => "authenticationFailed",
-2 => "userCancel",
-3 => "userFallback",
-4 => "systemCancel",
-5 => "passcodeNotSet",
-6 => "appCancel",
-7 => "biometryNotAvailable",
-8 => "biometryNotEnrolled",
-9 => "biometryLockout",
-10 => "invalidContext",
-1004 => "notInteractive",
_ => "unknown",
}
.to_string()
}
}
6 changes: 6 additions & 0 deletions plugins/biometric/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ pub enum Error {
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
#[cfg(desktop)]
#[error("Biometric authentication failed: {0}")]
AuthenticationFailed(String),
#[cfg(desktop)]
#[error("Biometric unavailable: {0}")]
Unavailable(String),
}

impl Serialize for Error {
Expand Down
Loading