Skip to main content

st/proxy/
token_store.rs

1//! Secure token storage for proxy OAuth providers.
2//!
3//! Uses the OS keychain (macOS Keychain / freedesktop Secret Service / Windows
4//! Credential Manager) via the `keyring` crate. Falls back to a 0600 file under
5//! `~/.st/proxy_tokens/` when no backend is available (headless Linux etc.).
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12const SERVICE: &str = "smart-tree-proxy";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct StoredToken {
16    pub access_token: String,
17    pub refresh_token: Option<String>,
18    pub expires_at: Option<DateTime<Utc>>,
19    pub scope: Option<String>,
20    pub token_type: Option<String>,
21}
22
23impl StoredToken {
24    pub fn is_expired(&self) -> bool {
25        match self.expires_at {
26            Some(exp) => Utc::now() >= exp - chrono::Duration::seconds(30),
27            None => false,
28        }
29    }
30}
31
32/// Save a token for `provider` under account `account` (email / user id / "default").
33pub fn save(provider: &str, account: &str, token: &StoredToken) -> Result<()> {
34    let payload = serde_json::to_string(token)?;
35    let entry_user = format!("{}:{}", provider, account);
36
37    match keyring::Entry::new(SERVICE, &entry_user).and_then(|e| e.set_password(&payload)) {
38        Ok(()) => Ok(()),
39        Err(_) => save_file(provider, account, &payload),
40    }
41}
42
43pub fn load(provider: &str, account: &str) -> Result<Option<StoredToken>> {
44    let entry_user = format!("{}:{}", provider, account);
45
46    if let Ok(entry) = keyring::Entry::new(SERVICE, &entry_user) {
47        match entry.get_password() {
48            Ok(payload) => return Ok(Some(serde_json::from_str(&payload)?)),
49            Err(keyring::Error::NoEntry) => return Ok(None),
50            Err(_) => {}
51        }
52    }
53    load_file(provider, account)
54}
55
56pub fn delete(provider: &str, account: &str) -> Result<()> {
57    let entry_user = format!("{}:{}", provider, account);
58    if let Ok(entry) = keyring::Entry::new(SERVICE, &entry_user) {
59        let _ = entry.delete_credential();
60    }
61    let path = file_path(provider, account)?;
62    if path.exists() {
63        std::fs::remove_file(path)?;
64    }
65    Ok(())
66}
67
68fn fallback_dir() -> Result<PathBuf> {
69    let home = std::env::var("HOME").context("HOME not set")?;
70    let dir = PathBuf::from(home).join(".st").join("proxy_tokens");
71    std::fs::create_dir_all(&dir)?;
72    Ok(dir)
73}
74
75fn file_path(provider: &str, account: &str) -> Result<PathBuf> {
76    let safe = |s: &str| s.replace(['/', '\\', ':'], "_");
77    Ok(fallback_dir()?.join(format!("{}__{}.json", safe(provider), safe(account))))
78}
79
80fn save_file(provider: &str, account: &str, payload: &str) -> Result<()> {
81    let path = file_path(provider, account)?;
82    std::fs::write(&path, payload)?;
83    #[cfg(unix)]
84    {
85        use std::os::unix::fs::PermissionsExt;
86        let mut perms = std::fs::metadata(&path)?.permissions();
87        perms.set_mode(0o600);
88        std::fs::set_permissions(&path, perms)?;
89    }
90    Ok(())
91}
92
93fn load_file(provider: &str, account: &str) -> Result<Option<StoredToken>> {
94    let path = file_path(provider, account)?;
95    if !path.exists() {
96        return Ok(None);
97    }
98    let payload = std::fs::read_to_string(path)?;
99    Ok(Some(serde_json::from_str(&payload)?))
100}