diff --git a/src/agentkit/server/frontend/src-tauri/Cargo.lock b/src/agentkit/server/frontend/src-tauri/Cargo.lock index 3eb6d4c..23941d5 100644 --- a/src/agentkit/server/frontend/src-tauri/Cargo.lock +++ b/src/agentkit/server/frontend/src-tauri/Cargo.lock @@ -79,6 +79,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "app" version = "0.1.0" dependencies = [ + "keyring", "log", "serde", "serde_json", @@ -1740,6 +1741,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "libappindicator" version = "0.9.0" diff --git a/src/agentkit/server/frontend/src-tauri/Cargo.toml b/src/agentkit/server/frontend/src-tauri/Cargo.toml index 74e42a1..672e9ef 100644 --- a/src/agentkit/server/frontend/src-tauri/Cargo.toml +++ b/src/agentkit/server/frontend/src-tauri/Cargo.toml @@ -24,3 +24,4 @@ log = "0.4" ureq = { version = "2.10", features = ["json"] } tauri = { version = "2.11.2", features = [] } tauri-plugin-log = "2" +keyring = "3" diff --git a/src/agentkit/server/frontend/src-tauri/src/auth.rs b/src/agentkit/server/frontend/src-tauri/src/auth.rs new file mode 100644 index 0000000..6330027 --- /dev/null +++ b/src/agentkit/server/frontend/src-tauri/src/auth.rs @@ -0,0 +1,105 @@ +//! Tauri 2.x commands for OS Keychain access. +//! +//! Stores the AgentKit refresh token in the platform-native credential +//! store instead of the WebView's localStorage. The WebView on macOS +//! stores `localStorage` in a plain SQLite file under +//! `~/Library/WebKit/.../LocalStorage/` — readable by any process with +//! the same UID. The Keychain is encrypted at rest, gated by the OS. +//! +//! Mapping per platform: +//! - macOS: Keychain Access.app +//! - Windows: Credential Manager +//! - Linux: Secret Service (gnome-keyring / kwallet) +//! +//! Frontend contract (see `src/api/tauri-auth.ts`): +//! - `store_refresh_token(token: String)` -> Result<(), String> +//! - `load_refresh_token()` -> Result, String> +//! - `clear_refresh_token()` -> Result<(), String> +//! +//! All three commands degrade gracefully: `NoEntry` (no credential +//! stored yet) is mapped to `Ok(None)` for load and `Ok(())` for clear, +//! so first-time login and re-login do not hit errors. +//! +//! Multi-user note +//! --------------- +//! The current scheme uses a single fixed keychain entry. If a single +//! macOS user account hosts multiple AgentKit users (e.g. a shared dev +//! laptop with several logins), they share the same keychain slot — +//! last-login-wins. This matches the V1 localStorage behavior and is +//! acceptable while AgentKit is single-user-per-device. When a future +//! "user switcher" feature lands, the key will become +//! `refresh_token::` so each user has their own slot. + +use log::warn; + +/// Keychain service identifier. Scoped to AgentKit (com.fischer.agentkit) +/// so it does not collide with other apps' credentials in the OS store. +const SERVICE: &str = "com.fischer.agentkit"; + +/// Keychain username slot. One slot per (service, username) pair; we +/// only need one for the single-refresh-token-per-device case. +const USERNAME: &str = "refresh_token"; + +fn entry() -> Result { + keyring::Entry::new(SERVICE, USERNAME).map_err(|e| format!("keychain init failed: {e}")) +} + +/// Store (or replace) the refresh token in the OS Keychain. +/// +/// The token is the only thing that needs durable storage — access +/// tokens live only in memory. See `auth.ts` for the storage strategy. +#[tauri::command] +pub async fn store_refresh_token(token: String) -> Result<(), String> { + let entry = entry()?; + entry + .set_password(&token) + .map_err(|e| format!("keychain write failed: {e}")) +} + +/// Load the refresh token from the OS Keychain. +/// +/// Returns `Ok(None)` when no credential has been stored (first launch +/// or after `clear_refresh_token`). This is the common case for a +/// freshly installed client and MUST NOT be reported as an error to +/// the frontend — it is the normal "log in to start" trigger. +#[tauri::command] +pub async fn load_refresh_token() -> Result, String> { + let entry = entry()?; + match entry.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keychain read failed: {e}")), + } +} + +/// Delete the refresh token from the OS Keychain. +/// +/// Like `load_refresh_token`, `NoEntry` is mapped to `Ok(())` so +/// repeated logout / clear-from-disk operations are idempotent and +/// never surface spurious errors to the frontend. +#[tauri::command] +pub async fn clear_refresh_token() -> Result<(), String> { + let entry = entry()?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => { + warn!("clear_refresh_token: no entry to delete (already cleared)"); + Ok(()) + } + Err(e) => Err(format!("keychain delete failed: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Smoke test that the SERVICE/USERNAME constants are stable. + /// Changing these would invalidate credentials stored on existing + /// users' machines, so the test guards against accidental drift. + #[test] + fn constants_are_stable() { + assert_eq!(SERVICE, "com.fischer.agentkit"); + assert_eq!(USERNAME, "refresh_token"); + } +} diff --git a/src/agentkit/server/frontend/src-tauri/src/lib.rs b/src/agentkit/server/frontend/src-tauri/src/lib.rs index dccc3da..12f5368 100644 --- a/src/agentkit/server/frontend/src-tauri/src/lib.rs +++ b/src/agentkit/server/frontend/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ use std::sync::Mutex; +mod auth; + /// Tauri 2.x IPC commands for AgentKit desktop client. /// /// Architecture: Tauri client connects to a remote AgentKit server (not a @@ -13,6 +15,11 @@ use std::sync::Mutex; /// - stop_backend() -> () /// - check_backend_health() -> bool (proxied HTTP GET to remote /api/v1/health) /// - get_backend_url() -> String (full base URL, e.g. http://127.0.0.1:8000) +/// +/// Auth commands (see `auth` module — store/load/clear refresh token in OS Keychain): +/// - store_refresh_token(token: String) -> Result<(), String> +/// - load_refresh_token() -> Result, String> +/// - clear_refresh_token() -> Result<(), String> /// Remote server connection info. Defaults match the local dev server /// (``agentkit serve --port 8000``). Override at compile time via @@ -125,6 +132,9 @@ pub fn run() { stop_backend, check_backend_health, get_backend_url, + auth::store_refresh_token, + auth::load_refresh_token, + auth::clear_refresh_token, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");