Skip to main content

sharepoint_cli/auth/
token_cache.rs

1//! On-disk token cache.
2//!
3//! Path: `~/.cache/sharepoint/tokens.json` (mode 0600).
4//! Keyed by `<tenant_id>:<client_id>:<oid>` so multiple accounts coexist.
5//! Atomic writes via tempfile-and-rename so a crashed write never corrupts
6//! the file or leaks the rotated refresh token alongside the previous one.
7
8use std::collections::BTreeMap;
9use std::io::Write;
10use std::path::Path;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::error::{CliError, Result};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TokenCache {
19    pub version: u32,
20    #[serde(default)]
21    pub entries: BTreeMap<String, CacheEntry>,
22}
23
24impl Default for TokenCache {
25    fn default() -> Self {
26        Self {
27            version: 1,
28            entries: BTreeMap::new(),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CacheEntry {
35    pub account: Account,
36    pub access_token: String,
37    pub access_token_expires_at: DateTime<Utc>,
38    pub refresh_token: Option<String>,
39    pub scopes: Vec<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct Account {
44    pub username: String,
45    pub name: Option<String>,
46    pub tenant_id: String,
47    pub oid: String,
48}
49
50pub fn cache_key(tenant_id: &str, client_id: &str, oid: &str) -> String {
51    format!("{tenant_id}:{client_id}:{oid}")
52}
53
54pub fn load(path: &Path) -> Result<TokenCache> {
55    let text = match std::fs::read_to_string(path) {
56        Ok(t) => t,
57        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
58            return Ok(TokenCache::default());
59        }
60        Err(e) => {
61            return Err(CliError::Other(format!("read {}: {e}", path.display())));
62        }
63    };
64    let cache: TokenCache = serde_json::from_str(&text)
65        .map_err(|e| CliError::Other(format!("parse {}: {e}", path.display())))?;
66    Ok(cache)
67}
68
69pub fn save(path: &Path, cache: &TokenCache) -> Result<()> {
70    let parent = path.parent().unwrap_or_else(|| Path::new("."));
71    std::fs::create_dir_all(parent)
72        .map_err(|e| CliError::Other(format!("mkdir {}: {e}", parent.display())))?;
73    let body = serde_json::to_vec_pretty(cache)
74        .map_err(|e| CliError::Other(format!("serialize tokens: {e}")))?;
75
76    let mut tmp = tempfile::Builder::new()
77        .prefix(".tokens-")
78        .suffix(".json.tmp")
79        .tempfile_in(parent)
80        .map_err(|e| CliError::Other(format!("tempfile in {}: {e}", parent.display())))?;
81    tmp.write_all(&body)
82        .map_err(|e| CliError::Other(format!("write tempfile: {e}")))?;
83    tmp.flush()
84        .map_err(|e| CliError::Other(format!("flush tempfile: {e}")))?;
85
86    set_mode_0600(tmp.path())?;
87    tmp.persist(path)
88        .map_err(|e| CliError::Other(format!("persist tempfile: {e}")))?;
89    Ok(())
90}
91
92#[cfg(unix)]
93fn set_mode_0600(path: &Path) -> Result<()> {
94    use std::os::unix::fs::PermissionsExt;
95    let perms = std::fs::Permissions::from_mode(0o600);
96    std::fs::set_permissions(path, perms)
97        .map_err(|e| CliError::Other(format!("chmod 0600 {}: {e}", path.display())))?;
98    Ok(())
99}
100
101#[cfg(not(unix))]
102fn set_mode_0600(_path: &Path) -> Result<()> {
103    // Windows ACLs are out of scope; the file is in %LOCALAPPDATA%\sharepoint\
104    // which is per-user by default. Documenting this in CONTRIBUTING.md is enough.
105    Ok(())
106}
107
108/// Replace one entry atomically (load → mutate → save).
109pub fn upsert(path: &Path, key: &str, entry: CacheEntry) -> Result<()> {
110    let mut cache = load(path)?;
111    cache.entries.insert(key.to_string(), entry);
112    save(path, &cache)
113}
114
115/// Remove one entry; no-op if absent.
116pub fn remove(path: &Path, key: &str) -> Result<bool> {
117    let mut cache = load(path)?;
118    let removed = cache.entries.remove(key).is_some();
119    if removed {
120        save(path, &cache)?;
121    }
122    Ok(removed)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use chrono::Duration;
129
130    fn sample_entry() -> CacheEntry {
131        CacheEntry {
132            account: Account {
133                username: "alice@contoso.com".into(),
134                name: Some("Alice Example".to_string()),
135                tenant_id: "tid-123".into(),
136                oid: "oid-456".into(),
137            },
138            access_token: "AT".into(),
139            access_token_expires_at: Utc::now() + Duration::minutes(60),
140            refresh_token: Some("RT".to_string()),
141            scopes: vec!["openid".into(), "User.Read".into()],
142        }
143    }
144
145    #[test]
146    fn cache_key_format() {
147        assert_eq!(cache_key("t", "c", "o"), "t:c:o");
148    }
149
150    #[test]
151    fn missing_file_returns_empty_cache() {
152        let dir = tempfile::tempdir().unwrap();
153        let path = dir.path().join("tokens.json");
154        let cache = load(&path).unwrap();
155        assert_eq!(cache.version, 1);
156        assert!(cache.entries.is_empty());
157    }
158
159    #[test]
160    fn upsert_then_load_round_trips() {
161        let dir = tempfile::tempdir().unwrap();
162        let path = dir.path().join("tokens.json");
163        upsert(&path, "k1", sample_entry()).unwrap();
164        let loaded = load(&path).unwrap();
165        let e = loaded.entries.get("k1").unwrap();
166        assert_eq!(e.account.username, "alice@contoso.com");
167        assert_eq!(e.refresh_token.as_deref(), Some("RT"));
168    }
169
170    #[cfg(unix)]
171    #[test]
172    fn save_uses_mode_0600() {
173        use std::os::unix::fs::PermissionsExt;
174        let dir = tempfile::tempdir().unwrap();
175        let path = dir.path().join("tokens.json");
176        upsert(&path, "k1", sample_entry()).unwrap();
177        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
178        assert_eq!(mode, 0o600);
179    }
180
181    #[test]
182    fn remove_returns_true_when_present_and_false_when_absent() {
183        let dir = tempfile::tempdir().unwrap();
184        let path = dir.path().join("tokens.json");
185        upsert(&path, "k1", sample_entry()).unwrap();
186        assert!(remove(&path, "k1").unwrap());
187        assert!(!remove(&path, "k1").unwrap());
188    }
189
190    #[test]
191    fn upsert_replaces_atomically() {
192        let dir = tempfile::tempdir().unwrap();
193        let path = dir.path().join("tokens.json");
194        let mut e1 = sample_entry();
195        e1.refresh_token = Some("RT-old".to_string());
196        upsert(&path, "k1", e1).unwrap();
197        let mut e2 = sample_entry();
198        e2.refresh_token = Some("RT-new".to_string());
199        upsert(&path, "k1", e2).unwrap();
200        let loaded = load(&path).unwrap();
201        assert_eq!(
202            loaded.entries.get("k1").unwrap().refresh_token.as_deref(),
203            Some("RT-new")
204        );
205    }
206}