feat(tauri): U5 OS Keychain commands (store/load/clear refresh token)

macOS 的 WebView 把 localStorage 存在明文 SQLite(`~/Library/WebKit/.../LocalStorage/`),
同 UID 任何进程可读。Refresh token 迁到 OS Keychain 加密落盘:
- macOS:   Keychain Access.app
- Windows: Credential Manager
- Linux:   Secret Service (gnome-keyring / kwallet)

变更
- Cargo.toml: 加 keyring = "3" 依赖
- src/auth.rs: 3 个 #[tauri::command] — store_refresh_token / load_refresh_token / clear_refresh_token
- src/lib.rs: mod auth + 注册 3 个 commands

设计要点
- SERVICE = "com.fischer.agentkit",USERNAME = "refresh_token",
  单 slot(last-login-wins),匹配 V1 localStorage 行为
- load / clear 都把 keyring::Error::NoEntry 映射为 Ok(None) / Ok(()),
  首次启动 / 重复登出不会触发错误
- 多用户切换器未来需要时把 key 改成 refresh_token::<user_id>

Tauri 2 capabilities 说明
- capabilities/default.json 不需要改:自定义 #[tauri::command] 默认允许,
  capabilities 仅管 plugin 命令(core:*、log:* 等)

验证
- cargo check: 通过
- cargo test --lib: 1 passed (constants_are_stable smoke test)

后续:U6 在前端封装 tauri-auth.ts adapter(keychain / localStorage fallback)
This commit is contained in:
chiguyong 2026-06-21 01:35:55 +08:00
parent d42c45e5ad
commit 7e7a841f78
4 changed files with 127 additions and 0 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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<Option<String>, 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::<user_id>` 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, String> {
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<Option<String>, 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");
}
}

View File

@ -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<Option<String>, 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");