use crate::Result;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone)]
pub struct CachedToken {
pub access_token: SecretString,
pub refresh_token: SecretString,
pub token_type: String,
pub device_token: String,
pub expires_at: Option<i64>,
}
#[derive(Serialize, Deserialize)]
struct CachedTokenOnDisk {
access_token: String,
refresh_token: String,
token_type: String,
device_token: String,
expires_at: Option<i64>,
}
impl From<&CachedToken> for CachedTokenOnDisk {
fn from(token: &CachedToken) -> Self {
Self {
access_token: token.access_token.expose_secret().to_owned(),
refresh_token: token.refresh_token.expose_secret().to_owned(),
token_type: token.token_type.clone(),
device_token: token.device_token.clone(),
expires_at: token.expires_at,
}
}
}
impl From<CachedTokenOnDisk> for CachedToken {
fn from(token: CachedTokenOnDisk) -> Self {
Self {
access_token: SecretString::from(token.access_token),
refresh_token: SecretString::from(token.refresh_token),
token_type: token.token_type,
device_token: token.device_token,
expires_at: token.expires_at,
}
}
}
impl std::fmt::Debug for CachedToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CachedToken")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("token_type", &self.token_type)
.field("device_token", &self.device_token)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct TokenCache {
path: PathBuf,
}
impl TokenCache {
pub fn with_path(path: PathBuf) -> Self {
Self { path }
}
pub fn load(&self) -> Result<Option<CachedToken>> {
if !self.path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&self.path)?;
let on_disk: CachedTokenOnDisk = match serde_json::from_str(&data) {
Ok(v) => v,
Err(err) => {
tracing::warn!(
path = %self.path.display(),
%err,
"Token cache is corrupt or unreadable so treating as absent; \
run `rhood login` to create a fresh session"
);
return Ok(None);
}
};
let token = CachedToken::from(on_disk);
if let Some(exp) = token.expires_at
&& chrono::Utc::now().timestamp() >= exp
{
self.clear()?;
return Ok(None);
}
Ok(Some(token))
}
pub fn save(&self, token: &CachedToken) -> Result<()> {
if let Some(parent) = self.path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let on_disk = CachedTokenOnDisk::from(token);
let data = serde_json::to_string_pretty(&on_disk)?;
Self::write_restricted(&self.path, data.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn write_restricted(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(data)?;
Ok(())
}
#[cfg(not(unix))]
fn write_restricted(path: &std::path::Path, data: &[u8]) -> Result<()> {
std::fs::write(path, data)?;
Ok(())
}
pub fn clear(&self) -> Result<()> {
if self.path.exists() {
std::fs::remove_file(&self.path)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_token(access: &str, expires_at: Option<i64>) -> CachedToken {
CachedToken {
access_token: SecretString::from(access),
refresh_token: SecretString::from("ref"),
token_type: "Bearer".into(),
device_token: "dev".into(),
expires_at,
}
}
#[test]
fn round_trip_token_cache() {
let dir = tempfile::tempdir().unwrap();
let cache = TokenCache::with_path(dir.path().join("token.json"));
assert!(cache.load().unwrap().is_none());
let token = make_token("acc", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
let loaded = cache.load().unwrap().unwrap();
assert_eq!(loaded.access_token.expose_secret(), "acc");
cache.clear().unwrap();
assert!(cache.load().unwrap().is_none());
}
#[test]
fn expired_token_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cache = TokenCache::with_path(dir.path().join("token.json"));
let token = make_token("old", Some(0));
cache.save(&token).unwrap();
assert!(cache.load().unwrap().is_none());
}
#[test]
fn corrupt_cache_returns_none_instead_of_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::write(&path, b"{\"not_a_token\": true}").unwrap();
let cache = TokenCache::with_path(path);
let result = cache.load().unwrap();
assert!(
result.is_none(),
"expected None for corrupt cache, got Some"
);
}
#[test]
fn completely_invalid_json_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::write(&path, b"this is not json at all!!!").unwrap();
let cache = TokenCache::with_path(path);
assert!(cache.load().unwrap().is_none());
}
#[cfg(unix)]
#[test]
fn token_file_has_restricted_permissions() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path.clone());
let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "Token file should be owner-only (0600)");
}
}