spotify_cli/storage/
token_store.rs

1//! OAuth token persistence.
2//!
3//! Stores tokens in JSON format in the user's config directory.
4//! Tokens are automatically saved after successful authentication
5//! and loaded on subsequent CLI invocations.
6//!
7//! ## Security
8//!
9//! Token files are stored with restrictive permissions (0600 on Unix)
10//! to prevent other users from reading sensitive credentials.
11
12use std::fs;
13use std::path::PathBuf;
14use thiserror::Error;
15
16use super::paths;
17use crate::oauth::token::Token;
18
19/// Errors that can occur when storing/loading tokens.
20#[derive(Debug, Error)]
21pub enum TokenStoreError {
22    #[error("Could not determine config directory: {0}")]
23    Path(#[from] paths::PathError),
24
25    #[error("Failed to create directory: {0}")]
26    CreateDir(#[from] std::io::Error),
27
28    #[error("Failed to serialize token: {0}")]
29    Serialize(#[from] serde_json::Error),
30
31    #[error("Token not found")]
32    NotFound,
33}
34
35/// Token storage manager.
36///
37/// Handles reading and writing OAuth tokens to disk.
38pub struct TokenStore {
39    path: PathBuf,
40}
41
42impl TokenStore {
43    /// Create a new token store using the default path.
44    pub fn new() -> Result<Self, TokenStoreError> {
45        let path = paths::token_file()?;
46        Ok(Self { path })
47    }
48
49    /// Save a token to disk.
50    ///
51    /// Creates the parent directory if it doesn't exist.
52    /// Sets restrictive file permissions (0600) on Unix systems.
53    pub fn save(&self, token: &Token) -> Result<(), TokenStoreError> {
54        if let Some(parent) = self.path.parent() {
55            fs::create_dir_all(parent)?;
56        }
57
58        let json = serde_json::to_string_pretty(token)?;
59        fs::write(&self.path, json)?;
60
61        // Set restrictive permissions on Unix
62        self.set_secure_permissions();
63
64        Ok(())
65    }
66
67    /// Set secure file permissions (owner read/write only).
68    ///
69    /// On Unix, sets mode to 0600 (rw-------).
70    /// On other platforms, this is a no-op (permissions handled by OS).
71    #[cfg(unix)]
72    fn set_secure_permissions(&self) {
73        use std::os::unix::fs::PermissionsExt;
74        use tracing::warn;
75
76        if let Ok(metadata) = fs::metadata(&self.path) {
77            let mut perms = metadata.permissions();
78            perms.set_mode(0o600); // Owner read/write only
79            if let Err(e) = fs::set_permissions(&self.path, perms) {
80                warn!(path = %self.path.display(), error = %e, "Failed to set secure permissions on token file");
81            }
82        }
83    }
84
85    #[cfg(not(unix))]
86    fn set_secure_permissions(&self) {
87        // On Windows, file permissions work differently via ACLs.
88        // The file is created with the user's default permissions,
89        // which is typically secure enough for single-user systems.
90    }
91
92    /// Load a token from disk.
93    ///
94    /// Returns `NotFound` error if no token file exists.
95    pub fn load(&self) -> Result<Token, TokenStoreError> {
96        if !self.path.exists() {
97            return Err(TokenStoreError::NotFound);
98        }
99
100        let json = fs::read_to_string(&self.path)?;
101        let token = serde_json::from_str(&json)?;
102
103        Ok(token)
104    }
105
106    /// Delete the stored token.
107    pub fn delete(&self) -> Result<(), TokenStoreError> {
108        if self.path.exists() {
109            fs::remove_file(&self.path)?;
110        }
111
112        Ok(())
113    }
114
115    /// Check if a token file exists.
116    pub fn exists(&self) -> bool {
117        self.path.exists()
118    }
119
120    /// Get the path to the token file.
121    pub fn path(&self) -> &PathBuf {
122        &self.path
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::oauth::token::SpotifyTokenResponse;
130    use std::env;
131
132    const TOKEN_FILE: &str = "token.json";
133
134    fn temp_store() -> TokenStore {
135        let temp_dir = env::temp_dir().join(format!("spotify-cli-test-{}", rand::random::<u64>()));
136        TokenStore {
137            path: temp_dir.join(TOKEN_FILE),
138        }
139    }
140
141    fn make_token() -> Token {
142        Token::from_response(SpotifyTokenResponse {
143            access_token: "test_access".to_string(),
144            token_type: "Bearer".to_string(),
145            scope: "user-read-playback-state".to_string(),
146            expires_in: 3600,
147            refresh_token: Some("test_refresh".to_string()),
148        })
149    }
150
151    #[test]
152    fn save_and_load_token() {
153        let store = temp_store();
154        let token = make_token();
155
156        store.save(&token).unwrap();
157        let loaded = store.load().unwrap();
158
159        assert_eq!(loaded.access_token, token.access_token);
160        assert_eq!(loaded.refresh_token, token.refresh_token);
161
162        store.delete().unwrap();
163    }
164
165    #[test]
166    fn load_nonexistent_returns_not_found() {
167        let store = temp_store();
168        let result = store.load();
169
170        assert!(matches!(result, Err(TokenStoreError::NotFound)));
171    }
172
173    #[test]
174    fn exists_returns_false_when_no_token() {
175        let store = temp_store();
176        assert!(!store.exists());
177    }
178
179    #[test]
180    fn exists_returns_true_after_save() {
181        let store = temp_store();
182        let token = make_token();
183
184        store.save(&token).unwrap();
185        assert!(store.exists());
186
187        store.delete().unwrap();
188    }
189
190    #[cfg(unix)]
191    #[test]
192    fn save_sets_secure_permissions() {
193        use std::os::unix::fs::PermissionsExt;
194
195        let store = temp_store();
196        let token = make_token();
197
198        store.save(&token).unwrap();
199
200        let metadata = fs::metadata(store.path()).unwrap();
201        let mode = metadata.permissions().mode();
202
203        // Check that the file mode is 0600 (owner read/write only)
204        // The mode includes the file type bits, so we mask to get just permissions
205        assert_eq!(
206            mode & 0o777,
207            0o600,
208            "Token file should have 0600 permissions"
209        );
210
211        store.delete().unwrap();
212    }
213
214    #[test]
215    fn delete_nonexistent_is_ok() {
216        let store = temp_store();
217        let result = store.delete();
218        assert!(result.is_ok());
219    }
220
221    #[test]
222    fn path_returns_correct_path() {
223        let store = temp_store();
224        assert!(store.path().to_str().unwrap().contains("token.json"));
225    }
226
227    #[test]
228    fn token_store_error_display() {
229        let err = TokenStoreError::NotFound;
230        let display = format!("{}", err);
231        assert!(display.contains("not found"));
232    }
233}