|
| 1 | +use std::fmt; |
| 2 | +use std::fs::{self, OpenOptions}; |
| 3 | +use std::io::Write; |
| 4 | +use std::path::{Path, PathBuf}; |
| 5 | +use std::time::{SystemTime, UNIX_EPOCH}; |
| 6 | + |
| 7 | +use serde::{Deserialize, Serialize}; |
| 8 | + |
| 9 | +use crate::services::auth::TokenResponse; |
| 10 | + |
| 11 | +const TOKEN_FILE_SUBPATH: &str = "sce/auth/tokens.json"; |
| 12 | + |
| 13 | +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] |
| 14 | +pub struct StoredTokens { |
| 15 | + pub access_token: String, |
| 16 | + pub token_type: String, |
| 17 | + pub expires_in: u64, |
| 18 | + pub refresh_token: String, |
| 19 | + pub scope: Option<String>, |
| 20 | + pub stored_at_unix_seconds: u64, |
| 21 | +} |
| 22 | + |
| 23 | +impl StoredTokens { |
| 24 | + fn from_token_response(token: &TokenResponse) -> Result<Self, TokenStorageError> { |
| 25 | + let stored_at_unix_seconds = current_unix_timestamp_seconds()?; |
| 26 | + Ok(Self { |
| 27 | + access_token: token.access_token.clone(), |
| 28 | + token_type: token.token_type.clone(), |
| 29 | + expires_in: token.expires_in, |
| 30 | + refresh_token: token.refresh_token.clone(), |
| 31 | + scope: token.scope.clone(), |
| 32 | + stored_at_unix_seconds, |
| 33 | + }) |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +#[derive(Debug)] |
| 38 | +pub enum TokenStorageError { |
| 39 | + PathResolution(String), |
| 40 | + Io(std::io::Error), |
| 41 | + Serialization(serde_json::Error), |
| 42 | + CorruptedTokenFile(String), |
| 43 | + Permission(String), |
| 44 | +} |
| 45 | + |
| 46 | +impl fmt::Display for TokenStorageError { |
| 47 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 48 | + match self { |
| 49 | + Self::PathResolution(reason) => write!( |
| 50 | + f, |
| 51 | + "Unable to resolve token storage path: {reason}. Try: set a valid user home/state directory and retry." |
| 52 | + ), |
| 53 | + Self::Io(error) => write!( |
| 54 | + f, |
| 55 | + "Failed to read or write authentication tokens: {error}. Try: verify file permissions for the auth state directory." |
| 56 | + ), |
| 57 | + Self::Serialization(error) => write!( |
| 58 | + f, |
| 59 | + "Failed to serialize authentication tokens: {error}. Try: rerun login to regenerate credentials." |
| 60 | + ), |
| 61 | + Self::CorruptedTokenFile(reason) => write!( |
| 62 | + f, |
| 63 | + "Stored authentication tokens are invalid: {reason}. Try: run 'sce logout' and then 'sce login'." |
| 64 | + ), |
| 65 | + Self::Permission(reason) => write!( |
| 66 | + f, |
| 67 | + "Unable to apply secure token file permissions: {reason}. Try: verify local account permissions and retry." |
| 68 | + ), |
| 69 | + } |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +impl std::error::Error for TokenStorageError {} |
| 74 | + |
| 75 | +impl From<std::io::Error> for TokenStorageError { |
| 76 | + fn from(value: std::io::Error) -> Self { |
| 77 | + Self::Io(value) |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +impl From<serde_json::Error> for TokenStorageError { |
| 82 | + fn from(value: serde_json::Error) -> Self { |
| 83 | + Self::Serialization(value) |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +pub fn save_tokens(token: &TokenResponse) -> Result<StoredTokens, TokenStorageError> { |
| 88 | + let token_path = token_file_path()?; |
| 89 | + let stored = StoredTokens::from_token_response(token)?; |
| 90 | + save_tokens_at_path(&token_path, &stored)?; |
| 91 | + Ok(stored) |
| 92 | +} |
| 93 | + |
| 94 | +pub fn load_tokens() -> Result<Option<StoredTokens>, TokenStorageError> { |
| 95 | + let token_path = token_file_path()?; |
| 96 | + load_tokens_from_path(&token_path) |
| 97 | +} |
| 98 | + |
| 99 | +pub fn token_file_path() -> Result<PathBuf, TokenStorageError> { |
| 100 | + #[cfg(target_os = "linux")] |
| 101 | + { |
| 102 | + return linux_token_file_path(); |
| 103 | + } |
| 104 | + |
| 105 | + #[cfg(any(target_os = "macos", target_os = "windows"))] |
| 106 | + { |
| 107 | + let Some(data_dir) = dirs::data_dir() else { |
| 108 | + return Err(TokenStorageError::PathResolution( |
| 109 | + "data directory could not be resolved".to_string(), |
| 110 | + )); |
| 111 | + }; |
| 112 | + return Ok(data_dir.join(TOKEN_FILE_SUBPATH)); |
| 113 | + } |
| 114 | + |
| 115 | + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] |
| 116 | + { |
| 117 | + if let Some(state_dir) = dirs::state_dir() { |
| 118 | + return Ok(state_dir.join(TOKEN_FILE_SUBPATH)); |
| 119 | + } |
| 120 | + if let Some(data_dir) = dirs::data_dir() { |
| 121 | + return Ok(data_dir.join(TOKEN_FILE_SUBPATH)); |
| 122 | + } |
| 123 | + Err(TokenStorageError::PathResolution( |
| 124 | + "state and data directories could not be resolved".to_string(), |
| 125 | + )) |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +fn save_tokens_at_path(path: &Path, stored: &StoredTokens) -> Result<(), TokenStorageError> { |
| 130 | + ensure_parent_directory(path)?; |
| 131 | + |
| 132 | + let mut file = OpenOptions::new() |
| 133 | + .create(true) |
| 134 | + .write(true) |
| 135 | + .truncate(true) |
| 136 | + .open(path)?; |
| 137 | + |
| 138 | + apply_secure_file_permissions(path)?; |
| 139 | + |
| 140 | + let encoded = serde_json::to_vec_pretty(stored)?; |
| 141 | + file.write_all(&encoded)?; |
| 142 | + file.write_all(b"\n")?; |
| 143 | + file.sync_all()?; |
| 144 | + |
| 145 | + Ok(()) |
| 146 | +} |
| 147 | + |
| 148 | +fn load_tokens_from_path(path: &Path) -> Result<Option<StoredTokens>, TokenStorageError> { |
| 149 | + let content = match fs::read_to_string(path) { |
| 150 | + Ok(content) => content, |
| 151 | + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
| 152 | + Err(error) => return Err(TokenStorageError::Io(error)), |
| 153 | + }; |
| 154 | + |
| 155 | + let parsed: StoredTokens = serde_json::from_str(&content).map_err(|error| { |
| 156 | + TokenStorageError::CorruptedTokenFile(format!("{} ({error})", path.display())) |
| 157 | + })?; |
| 158 | + |
| 159 | + Ok(Some(parsed)) |
| 160 | +} |
| 161 | + |
| 162 | +fn ensure_parent_directory(path: &Path) -> Result<(), TokenStorageError> { |
| 163 | + let Some(parent) = path.parent() else { |
| 164 | + return Err(TokenStorageError::PathResolution(format!( |
| 165 | + "token path '{}' has no parent directory", |
| 166 | + path.display() |
| 167 | + ))); |
| 168 | + }; |
| 169 | + |
| 170 | + fs::create_dir_all(parent)?; |
| 171 | + apply_secure_directory_permissions(parent)?; |
| 172 | + Ok(()) |
| 173 | +} |
| 174 | + |
| 175 | +#[cfg(target_os = "linux")] |
| 176 | +fn linux_token_file_path() -> Result<PathBuf, TokenStorageError> { |
| 177 | + if let Some(state_dir) = dirs::state_dir() { |
| 178 | + return Ok(state_dir.join(TOKEN_FILE_SUBPATH)); |
| 179 | + } |
| 180 | + |
| 181 | + let Some(home_dir) = dirs::home_dir() else { |
| 182 | + return Err(TokenStorageError::PathResolution( |
| 183 | + "home directory could not be resolved for Linux fallback".to_string(), |
| 184 | + )); |
| 185 | + }; |
| 186 | + |
| 187 | + Ok(home_dir |
| 188 | + .join(".local") |
| 189 | + .join("state") |
| 190 | + .join(TOKEN_FILE_SUBPATH)) |
| 191 | +} |
| 192 | + |
| 193 | +fn current_unix_timestamp_seconds() -> Result<u64, TokenStorageError> { |
| 194 | + Ok(SystemTime::now() |
| 195 | + .duration_since(UNIX_EPOCH) |
| 196 | + .map_err(|error| { |
| 197 | + TokenStorageError::PathResolution(format!("system clock is invalid: {error}")) |
| 198 | + })? |
| 199 | + .as_secs()) |
| 200 | +} |
| 201 | + |
| 202 | +#[cfg(unix)] |
| 203 | +fn apply_secure_directory_permissions(path: &Path) -> Result<(), TokenStorageError> { |
| 204 | + use std::os::unix::fs::PermissionsExt; |
| 205 | + |
| 206 | + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; |
| 207 | + Ok(()) |
| 208 | +} |
| 209 | + |
| 210 | +#[cfg(not(unix))] |
| 211 | +fn apply_secure_directory_permissions(_path: &Path) -> Result<(), TokenStorageError> { |
| 212 | + Ok(()) |
| 213 | +} |
| 214 | + |
| 215 | +#[cfg(unix)] |
| 216 | +fn apply_secure_file_permissions(path: &Path) -> Result<(), TokenStorageError> { |
| 217 | + use std::os::unix::fs::PermissionsExt; |
| 218 | + |
| 219 | + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; |
| 220 | + Ok(()) |
| 221 | +} |
| 222 | + |
| 223 | +#[cfg(windows)] |
| 224 | +fn apply_secure_file_permissions(path: &Path) -> Result<(), TokenStorageError> { |
| 225 | + use std::process::Command; |
| 226 | + |
| 227 | + let username = std::env::var("USERNAME").map_err(|_| { |
| 228 | + TokenStorageError::Permission( |
| 229 | + "USERNAME environment variable is unavailable on Windows".to_string(), |
| 230 | + ) |
| 231 | + })?; |
| 232 | + |
| 233 | + let grant_rule = format!("{username}:(R,W)"); |
| 234 | + let output = Command::new("icacls") |
| 235 | + .arg(path) |
| 236 | + .arg("/inheritance:r") |
| 237 | + .arg("/grant:r") |
| 238 | + .arg(grant_rule) |
| 239 | + .output() |
| 240 | + .map_err(|error| { |
| 241 | + TokenStorageError::Permission(format!("failed to execute icacls: {error}")) |
| 242 | + })?; |
| 243 | + |
| 244 | + if output.status.success() { |
| 245 | + Ok(()) |
| 246 | + } else { |
| 247 | + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); |
| 248 | + Err(TokenStorageError::Permission(format!( |
| 249 | + "icacls failed for '{}': {stderr}", |
| 250 | + path.display() |
| 251 | + ))) |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +#[cfg(not(any(unix, windows)))] |
| 256 | +fn apply_secure_file_permissions(_path: &Path) -> Result<(), TokenStorageError> { |
| 257 | + Ok(()) |
| 258 | +} |
| 259 | + |
| 260 | +#[cfg(test)] |
| 261 | +mod tests { |
| 262 | + use super::{load_tokens_from_path, save_tokens_at_path, StoredTokens}; |
| 263 | + use std::fs; |
| 264 | + use std::path::PathBuf; |
| 265 | + |
| 266 | + fn unique_test_path(test_name: &str) -> PathBuf { |
| 267 | + let unique = format!( |
| 268 | + "sce-token-storage-{}-{}-{}", |
| 269 | + test_name, |
| 270 | + std::process::id(), |
| 271 | + std::time::SystemTime::now() |
| 272 | + .duration_since(std::time::UNIX_EPOCH) |
| 273 | + .expect("system clock should be after unix epoch") |
| 274 | + .as_nanos() |
| 275 | + ); |
| 276 | + std::env::temp_dir().join(unique).join("tokens.json") |
| 277 | + } |
| 278 | + |
| 279 | + fn fixture_tokens() -> StoredTokens { |
| 280 | + StoredTokens { |
| 281 | + access_token: "access-token".to_string(), |
| 282 | + token_type: "Bearer".to_string(), |
| 283 | + expires_in: 3600, |
| 284 | + refresh_token: "refresh-token".to_string(), |
| 285 | + scope: Some("openid profile".to_string()), |
| 286 | + stored_at_unix_seconds: 1_700_000_000, |
| 287 | + } |
| 288 | + } |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn save_and_load_round_trip() { |
| 292 | + let token_path = unique_test_path("round-trip"); |
| 293 | + let tokens = fixture_tokens(); |
| 294 | + |
| 295 | + save_tokens_at_path(&token_path, &tokens).expect("tokens should save"); |
| 296 | + |
| 297 | + let loaded = load_tokens_from_path(&token_path) |
| 298 | + .expect("load should succeed") |
| 299 | + .expect("tokens should exist"); |
| 300 | + assert_eq!(loaded, tokens); |
| 301 | + |
| 302 | + let _ = fs::remove_dir_all( |
| 303 | + token_path |
| 304 | + .parent() |
| 305 | + .and_then(|parent| parent.parent()) |
| 306 | + .expect("temp tree should have two parent levels"), |
| 307 | + ); |
| 308 | + } |
| 309 | + |
| 310 | + #[test] |
| 311 | + fn load_missing_token_file_returns_none() { |
| 312 | + let token_path = unique_test_path("missing-file"); |
| 313 | + let loaded = load_tokens_from_path(&token_path).expect("missing file should not error"); |
| 314 | + assert!(loaded.is_none()); |
| 315 | + } |
| 316 | + |
| 317 | + #[test] |
| 318 | + fn load_invalid_json_returns_corruption_error() { |
| 319 | + let token_path = unique_test_path("invalid-json"); |
| 320 | + let parent = token_path.parent().expect("token file should have parent"); |
| 321 | + fs::create_dir_all(parent).expect("should create parent directory"); |
| 322 | + fs::write(&token_path, "{not valid json").expect("should write invalid payload"); |
| 323 | + |
| 324 | + let error = load_tokens_from_path(&token_path).expect_err("invalid json should fail"); |
| 325 | + let message = error.to_string(); |
| 326 | + assert!(message.contains("Stored authentication tokens are invalid")); |
| 327 | + |
| 328 | + let _ = fs::remove_dir_all( |
| 329 | + token_path |
| 330 | + .parent() |
| 331 | + .and_then(|path| path.parent()) |
| 332 | + .expect("temp tree should have two parent levels"), |
| 333 | + ); |
| 334 | + } |
| 335 | + |
| 336 | + #[cfg(unix)] |
| 337 | + #[test] |
| 338 | + fn save_sets_unix_file_permissions_to_0600() { |
| 339 | + use std::os::unix::fs::PermissionsExt; |
| 340 | + |
| 341 | + let token_path = unique_test_path("unix-perms"); |
| 342 | + save_tokens_at_path(&token_path, &fixture_tokens()).expect("tokens should save"); |
| 343 | + |
| 344 | + let metadata = fs::metadata(&token_path).expect("token file should exist"); |
| 345 | + let mode = metadata.permissions().mode() & 0o777; |
| 346 | + assert_eq!(mode, 0o600); |
| 347 | + |
| 348 | + let _ = fs::remove_dir_all( |
| 349 | + token_path |
| 350 | + .parent() |
| 351 | + .and_then(|path| path.parent()) |
| 352 | + .expect("temp tree should have two parent levels"), |
| 353 | + ); |
| 354 | + } |
| 355 | +} |
0 commit comments