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"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -1740,6 +1741,16 @@ dependencies = [
|
||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "libappindicator"
|
name = "libappindicator"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,4 @@ log = "0.4"
|
||||||
ureq = { version = "2.10", features = ["json"] }
|
ureq = { version = "2.10", features = ["json"] }
|
||||||
tauri = { version = "2.11.2", features = [] }
|
tauri = { version = "2.11.2", features = [] }
|
||||||
tauri-plugin-log = "2"
|
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;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
|
||||||
/// Tauri 2.x IPC commands for AgentKit desktop client.
|
/// Tauri 2.x IPC commands for AgentKit desktop client.
|
||||||
///
|
///
|
||||||
/// Architecture: Tauri client connects to a remote AgentKit server (not a
|
/// Architecture: Tauri client connects to a remote AgentKit server (not a
|
||||||
|
|
@ -13,6 +15,11 @@ use std::sync::Mutex;
|
||||||
/// - stop_backend() -> ()
|
/// - stop_backend() -> ()
|
||||||
/// - check_backend_health() -> bool (proxied HTTP GET to remote /api/v1/health)
|
/// - 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)
|
/// - 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
|
/// Remote server connection info. Defaults match the local dev server
|
||||||
/// (``agentkit serve --port 8000``). Override at compile time via
|
/// (``agentkit serve --port 8000``). Override at compile time via
|
||||||
|
|
@ -125,6 +132,9 @@ pub fn run() {
|
||||||
stop_backend,
|
stop_backend,
|
||||||
check_backend_health,
|
check_backend_health,
|
||||||
get_backend_url,
|
get_backend_url,
|
||||||
|
auth::store_refresh_token,
|
||||||
|
auth::load_refresh_token,
|
||||||
|
auth::clear_refresh_token,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue