use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
pub access_token: String,
#[serde(default)]
pub expires_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}
pub struct TokenStore {
path: PathBuf,
tokens: HashMap<String, OAuthToken>,
}
impl TokenStore {
pub fn load(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let tokens = Self::read_file(&path).unwrap_or_default();
Self { path, tokens }
}
pub fn get(&self, provider: &str) -> Option<&OAuthToken> {
let token = self.tokens.get(provider)?;
if token.expires_at != 0 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now >= token.expires_at {
return None;
}
}
Some(token)
}
pub fn set(
&mut self,
provider: &str,
token: OAuthToken,
) -> Result<(), Box<dyn std::error::Error>> {
self.tokens.insert(provider.to_owned(), token);
self.write_file()
}
fn read_file(path: &Path) -> Option<HashMap<String, OAuthToken>> {
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn write_file(&self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&self.tokens)?;
std::fs::write(&self.path, json)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_token_store() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("tokens.json");
let mut store = TokenStore::load(&path);
assert!(store.get("github-copilot").is_none());
store
.set(
"github-copilot",
OAuthToken {
access_token: "ghu_test123".into(),
expires_at: 0,
refresh_token: None,
},
)
.expect("write");
assert_eq!(
store.get("github-copilot").map(|t| &t.access_token),
Some(&"ghu_test123".to_owned()),
);
let reloaded = TokenStore::load(&path);
assert_eq!(
reloaded.get("github-copilot").map(|t| &t.access_token),
Some(&"ghu_test123".to_owned()),
);
}
#[test]
fn expired_token_returns_none() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("tokens.json");
let mut store = TokenStore::load(&path);
store
.set(
"expired-provider",
OAuthToken {
access_token: "expired".into(),
expires_at: 1, refresh_token: None,
},
)
.expect("write");
assert!(store.get("expired-provider").is_none());
}
#[test]
fn non_expiring_token_always_valid() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("tokens.json");
let mut store = TokenStore::load(&path);
store
.set(
"forever",
OAuthToken {
access_token: "eternal".into(),
expires_at: 0,
refresh_token: None,
},
)
.expect("write");
assert!(store.get("forever").is_some());
}
#[test]
fn load_missing_file_returns_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nonexistent_tokens.json");
let store = TokenStore::load(&path);
assert!(store.get("anything").is_none());
}
}