Skip to main content

rhood_core/auth/
token_cache.rs

1//! Disk-backed OAuth token cache for the Robinhood API client.
2//!
3//! [`CachedToken`] holds the credential fields needed to restore a session,
4//! and [`TokenCache`] manages reading and writing those tokens as a JSON file
5//! with restricted file permissions on Unix.
6
7use 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/// Represents a set of cached OAuth credentials for a Robinhood session.
15#[derive(Clone)]
16pub struct CachedToken {
17    /// The OAuth access token used to authorize API requests.
18    pub access_token: SecretString,
19    /// The OAuth refresh token used to obtain a new access token.
20    pub refresh_token: SecretString,
21    /// The token type prefix for the `Authorization` header (typically `"Bearer"`).
22    pub token_type: String,
23    /// The device token identifying this client to the Robinhood API.
24    pub device_token: String,
25    /// The Unix timestamp (seconds) at which the access token expires, if known.
26    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/// Manages reading and writing [`CachedToken`] values to a JSON file on disk.
76///
77/// On Unix systems, the token file is created with mode `0600` (owner read/write only).
78#[derive(Debug, Clone)]
79pub struct TokenCache {
80    path: PathBuf,
81}
82
83impl TokenCache {
84    /// Creates a new `TokenCache` that reads from and writes to the given file path.
85    pub fn with_path(path: PathBuf) -> Self {
86        Self { path }
87    }
88
89    /// Loads a cached token from disk.
90    ///
91    /// Returns `None` if the file does not exist, contains malformed or unrecognized
92    /// JSON, or holds an expired token. A corrupt cache is treated the same as an
93    /// absent one so the caller can prompt the user to log in again rather than
94    /// surfacing a raw deserialization error.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the cache has insecure permissions or another filesystem
99    /// I/O operation fails.
100    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    /// Serializes and writes the given token to disk with restricted file permissions.
130    ///
131    /// Creates parent directories as needed so callers can pass a path whose
132    /// ancestors do not yet exist (e.g. `~/.rhood/.rhood-token` on a fresh host).
133    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    /// Deletes the cached token file from disk, if it exists.
197    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        // Write malformed JSON that will fail CachedTokenOnDisk deserialization.
281        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        // A corrupt cache should be treated as absent, not as a hard error.
286        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}