1use crate::Result;
8#[cfg(unix)]
9use crate::config::ensure_secret_file_permissions;
10use secrecy::{ExposeSecret, SecretString};
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13
14#[derive(Clone)]
16pub struct CachedToken {
17 pub access_token: SecretString,
19 pub refresh_token: SecretString,
21 pub token_type: String,
23 pub device_token: String,
25 pub expires_at: Option<i64>,
27}
28
29#[derive(Serialize, Deserialize)]
30struct CachedTokenOnDisk {
31 access_token: String,
32 refresh_token: String,
33 token_type: String,
34 device_token: String,
35 expires_at: Option<i64>,
36}
37
38impl From<&CachedToken> for CachedTokenOnDisk {
39 fn from(token: &CachedToken) -> Self {
40 Self {
41 access_token: token.access_token.expose_secret().to_owned(),
42 refresh_token: token.refresh_token.expose_secret().to_owned(),
43 token_type: token.token_type.clone(),
44 device_token: token.device_token.clone(),
45 expires_at: token.expires_at,
46 }
47 }
48}
49
50impl From<CachedTokenOnDisk> for CachedToken {
51 fn from(token: CachedTokenOnDisk) -> Self {
52 Self {
53 access_token: SecretString::from(token.access_token),
54 refresh_token: SecretString::from(token.refresh_token),
55 token_type: token.token_type,
56 device_token: token.device_token,
57 expires_at: token.expires_at,
58 }
59 }
60}
61
62impl std::fmt::Debug for CachedToken {
63 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 formatter
65 .debug_struct("CachedToken")
66 .field("access_token", &"[REDACTED]")
67 .field("refresh_token", &"[REDACTED]")
68 .field("token_type", &self.token_type)
69 .field("device_token", &self.device_token)
70 .field("expires_at", &self.expires_at)
71 .finish()
72 }
73}
74
75#[derive(Debug, Clone)]
79pub struct TokenCache {
80 path: PathBuf,
81}
82
83impl TokenCache {
84 pub fn with_path(path: PathBuf) -> Self {
86 Self { path }
87 }
88
89 pub fn load(&self) -> Result<Option<CachedToken>> {
101 if !self.path.try_exists()? {
102 return Ok(None);
103 }
104 #[cfg(unix)]
105 ensure_secret_file_permissions(&self.path)?;
106 let data = std::fs::read_to_string(&self.path)?;
107 let on_disk: CachedTokenOnDisk = match serde_json::from_str(&data) {
108 Ok(v) => v,
109 Err(err) => {
110 tracing::warn!(
111 path = %self.path.display(),
112 %err,
113 "Token cache is corrupt or unrecognized so treating as absent; \
114 run `rhood login` to create a fresh session"
115 );
116 return Ok(None);
117 }
118 };
119 let token = CachedToken::from(on_disk);
120 if let Some(exp) = token.expires_at
121 && chrono::Utc::now().timestamp() >= exp
122 {
123 self.clear()?;
124 return Ok(None);
125 }
126 Ok(Some(token))
127 }
128
129 pub fn save(&self, token: &CachedToken) -> Result<()> {
134 if let Some(parent) = self.path.parent()
135 && !parent.as_os_str().is_empty()
136 {
137 std::fs::create_dir_all(parent)?;
138 }
139 let on_disk = CachedTokenOnDisk::from(token);
140 let data = serde_json::to_string_pretty(&on_disk)?;
141 Self::write_restricted(&self.path, data.as_bytes())?;
142 Ok(())
143 }
144
145 fn write_restricted(path: &Path, data: &[u8]) -> Result<()> {
146 use std::io::Write;
147
148 let parent = path
149 .parent()
150 .filter(|parent| !parent.as_os_str().is_empty())
151 .unwrap_or_else(|| Path::new("."));
152 let mut temporary = tempfile::Builder::new()
153 .prefix(".rhood-token-")
154 .tempfile_in(parent)?;
155
156 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159
160 if let Err(error) = temporary
161 .as_file()
162 .set_permissions(std::fs::Permissions::from_mode(0o600))
163 {
164 return Self::fail_and_cleanup(temporary, error);
165 }
166 }
167
168 if let Err(error) = temporary.write_all(data) {
169 return Self::fail_and_cleanup(temporary, error);
170 }
171 if let Err(error) = temporary.as_file().sync_all() {
172 return Self::fail_and_cleanup(temporary, error);
173 }
174
175 match temporary.persist(path) {
176 Ok(file) => drop(file),
177 Err(error) => return Self::fail_and_cleanup(error.file, error.error),
178 }
179
180 #[cfg(unix)]
181 std::fs::File::open(parent)?.sync_all()?;
182
183 Ok(())
184 }
185
186 fn fail_and_cleanup(
187 temporary: tempfile::NamedTempFile,
188 operation_error: std::io::Error,
189 ) -> Result<()> {
190 if let Err(cleanup_error) = temporary.close() {
191 tracing::warn!(%cleanup_error, "Failed to clean up token-cache temporary file");
192 }
193 Err(operation_error.into())
194 }
195
196 pub fn clear(&self) -> Result<()> {
198 if self.path.exists() {
199 std::fs::remove_file(&self.path)?;
200 }
201 Ok(())
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn make_token(access: &str, expires_at: Option<i64>) -> CachedToken {
210 CachedToken {
211 access_token: SecretString::from(access),
212 refresh_token: SecretString::from("ref"),
213 token_type: "Bearer".into(),
214 device_token: "dev".into(),
215 expires_at,
216 }
217 }
218
219 #[test]
220 fn round_trip_token_cache() {
221 let dir = tempfile::tempdir().unwrap();
222 let cache = TokenCache::with_path(dir.path().join("token.json"));
223
224 assert!(cache.load().unwrap().is_none());
225
226 let token = make_token("acc", Some(chrono::Utc::now().timestamp() + 3600));
227 cache.save(&token).unwrap();
228
229 let loaded = cache.load().unwrap().unwrap();
230 assert_eq!(loaded.access_token.expose_secret(), "acc");
231
232 cache.clear().unwrap();
233 assert!(cache.load().unwrap().is_none());
234 }
235
236 #[test]
237 fn save_atomically_replaces_existing_cache() {
238 let dir = tempfile::tempdir().unwrap();
239 let path = dir.path().join("token.json");
240 let cache = TokenCache::with_path(path);
241
242 cache.save(&make_token("old", None)).unwrap();
243 cache.save(&make_token("new", None)).unwrap();
244
245 let loaded = cache.load().unwrap().unwrap();
246 assert_eq!(loaded.access_token.expose_secret(), "new");
247 }
248
249 #[test]
250 fn failed_replacement_cleans_up_temporary_file() {
251 let dir = tempfile::tempdir().unwrap();
252 let path = dir.path().join("token.json");
253 std::fs::create_dir(&path).unwrap();
254 std::fs::write(path.join("keep"), b"force replacement failure").unwrap();
255 let cache = TokenCache::with_path(path.clone());
256
257 assert!(cache.save(&make_token("new", None)).is_err());
258
259 let entries = std::fs::read_dir(dir.path())
260 .unwrap()
261 .map(|entry| entry.unwrap().file_name())
262 .collect::<Vec<_>>();
263 assert_eq!(entries, vec![path.file_name().unwrap()]);
264 }
265
266 #[test]
267 fn expired_token_returns_none() {
268 let dir = tempfile::tempdir().unwrap();
269 let cache = TokenCache::with_path(dir.path().join("token.json"));
270
271 let token = make_token("old", Some(0));
272 cache.save(&token).unwrap();
273 assert!(cache.load().unwrap().is_none());
274 }
275
276 #[test]
277 fn corrupt_cache_returns_none_instead_of_error() {
278 let dir = tempfile::tempdir().unwrap();
279 let path = dir.path().join("token.json");
280 std::fs::write(&path, b"{\"not_a_token\": true}").unwrap();
282 #[cfg(unix)]
283 set_mode(&path, 0o600);
284 let cache = TokenCache::with_path(path);
285 let result = cache.load().unwrap();
287 assert!(
288 result.is_none(),
289 "expected None for corrupt cache, got Some"
290 );
291 }
292
293 #[test]
294 fn completely_invalid_json_returns_none() {
295 let dir = tempfile::tempdir().unwrap();
296 let path = dir.path().join("token.json");
297 std::fs::write(&path, b"this is not json at all!!!").unwrap();
298 #[cfg(unix)]
299 set_mode(&path, 0o600);
300 let cache = TokenCache::with_path(path);
301 assert!(cache.load().unwrap().is_none());
302 }
303
304 #[cfg(unix)]
305 fn set_mode(path: &std::path::Path, mode: u32) {
306 use std::os::unix::fs::PermissionsExt;
307
308 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
309 }
310
311 #[cfg(unix)]
312 #[test]
313 fn owner_only_token_file_loads() {
314 let dir = tempfile::tempdir().unwrap();
315 let path = dir.path().join("token.json");
316 let cache = TokenCache::with_path(path.clone());
317 let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
318
319 cache.save(&token).unwrap();
320 set_mode(&path, 0o600);
321
322 assert!(cache.load().unwrap().is_some());
323 }
324
325 #[cfg(unix)]
326 #[test]
327 fn group_readable_token_file_is_rejected() {
328 let dir = tempfile::tempdir().unwrap();
329 let path = dir.path().join("token.json");
330 let cache = TokenCache::with_path(path.clone());
331 let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
332
333 cache.save(&token).unwrap();
334 set_mode(&path, 0o644);
335
336 let error = cache.load().unwrap_err();
337 assert!(error.to_string().contains(&path.display().to_string()));
338 }
339
340 #[cfg(unix)]
341 #[test]
342 fn save_tightens_existing_token_file_permissions() {
343 use std::os::unix::fs::MetadataExt;
344
345 let dir = tempfile::tempdir().unwrap();
346 let path = dir.path().join("token.json");
347 let cache = TokenCache::with_path(path.clone());
348 let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
349
350 cache.save(&token).unwrap();
351 set_mode(&path, 0o644);
352 cache.save(&token).unwrap();
353
354 let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
355 assert_eq!(mode, 0o600, "Token file should be owner-only (0600)");
356 }
357
358 #[cfg(unix)]
359 #[test]
360 fn failed_save_preserves_existing_cache() {
361 let dir = tempfile::tempdir().unwrap();
362 let path = dir.path().join("token.json");
363 let cache = TokenCache::with_path(path);
364 cache.save(&make_token("old", None)).unwrap();
365
366 set_mode(dir.path(), 0o500);
367 let result = cache.save(&make_token("new", None));
368 set_mode(dir.path(), 0o700);
369
370 assert!(result.is_err());
371 let loaded = cache.load().unwrap().unwrap();
372 assert_eq!(loaded.access_token.expose_secret(), "old");
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn token_file_has_restricted_permissions() {
378 use std::os::unix::fs::MetadataExt;
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join("token.json");
381 let cache = TokenCache::with_path(path.clone());
382
383 let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
384 cache.save(&token).unwrap();
385
386 let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
387 assert_eq!(mode, 0o600, "Token file should be owner-only (0600)");
388 }
389}