sharepoint_cli/auth/
token_cache.rs1use 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 Ok(())
106}
107
108pub 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
115pub 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}