Skip to content

Commit 8afd3fa

Browse files
committed
token_storage: Implement WorkOS token persistence
Add secure file-based token storage with cross-platform path resolution, JSON serialization, and restrictive file permissions
1 parent 97bf6ff commit 8afd3fa

4 files changed

Lines changed: 362 additions & 4 deletions

File tree

cli/src/services/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
pub mod auth;
21
pub mod agent_trace;
2+
pub mod auth;
33
pub mod completion;
44
pub mod config;
55
pub mod doctor;
@@ -13,4 +13,5 @@ pub mod resilience;
1313
pub mod security;
1414
pub mod setup;
1515
pub mod sync;
16+
pub mod token_storage;
1617
pub mod version;

cli/src/services/token_storage.rs

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
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

Comments
 (0)