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:
parent
d42c45e5ad
commit
7e7a841f78
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue