use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::fs;
use tracing::debug;
const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token";
const TOKEN_REFRESH_BUFFER: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubToken {
pub access_token: String,
pub created_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopilotToken {
pub token: String,
pub expires_at: u64,
pub refresh_in: u64,
pub organization_list: Option<Vec<String>>,
}
#[derive(Clone)]
pub struct TokenManager {
config_dir: PathBuf,
client: reqwest::Client,
}
impl TokenManager {
pub fn new() -> Result<Self> {
let config_dir = Self::get_config_dir()?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
Ok(Self { config_dir, client })
}
fn get_config_dir() -> Result<PathBuf> {
let base_dir = dirs::config_dir().context("Failed to get config directory")?;
let config_dir = base_dir.join("edgequake").join("copilot");
Ok(config_dir)
}
fn github_token_path(&self) -> PathBuf {
self.config_dir.join("github_token.json")
}
fn copilot_token_path(&self) -> PathBuf {
self.config_dir.join("copilot_token.json")
}
async fn ensure_config_dir(&self) -> Result<()> {
fs::create_dir_all(&self.config_dir)
.await
.context("Failed to create config directory")?;
Ok(())
}
pub async fn save_github_token(&self, access_token: String) -> Result<()> {
self.ensure_config_dir().await?;
let token = GitHubToken {
access_token,
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
};
let json =
serde_json::to_string_pretty(&token).context("Failed to serialize GitHub token")?;
fs::write(self.github_token_path(), json)
.await
.context("Failed to write GitHub token")?;
Ok(())
}
pub async fn load_github_token(&self) -> Result<GitHubToken> {
let json = fs::read_to_string(self.github_token_path())
.await
.context("Failed to read GitHub token")?;
serde_json::from_str(&json).context("Failed to parse GitHub token")
}
pub async fn save_copilot_token(&self, token: CopilotToken) -> Result<()> {
self.ensure_config_dir().await?;
let json =
serde_json::to_string_pretty(&token).context("Failed to serialize Copilot token")?;
fs::write(self.copilot_token_path(), json)
.await
.context("Failed to write Copilot token")?;
Ok(())
}
pub async fn load_copilot_token(&self) -> Result<CopilotToken> {
let json = fs::read_to_string(self.copilot_token_path())
.await
.context("Failed to read Copilot token")?;
serde_json::from_str(&json).context("Failed to parse Copilot token")
}
pub fn needs_refresh(&self, token: &CopilotToken) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let refresh_at = token.expires_at.saturating_sub(TOKEN_REFRESH_BUFFER);
now >= refresh_at
}
pub async fn fetch_copilot_token(&self, github_token: &str) -> Result<CopilotToken> {
let response = self
.client
.get(COPILOT_TOKEN_URL)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {}", github_token))
.header("User-Agent", "GitHubCopilot/0.26.7")
.send()
.await
.context("Failed to fetch Copilot token")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Copilot token request failed: {} - {}", status, body);
}
#[derive(Deserialize)]
struct CopilotTokenResponse {
token: String,
expires_at: u64,
refresh_in: Option<u64>,
organization_list: Option<Vec<String>>,
}
let resp: CopilotTokenResponse = response
.json()
.await
.context("Failed to parse Copilot token response")?;
Ok(CopilotToken {
token: resp.token,
expires_at: resp.expires_at,
refresh_in: resp.refresh_in.unwrap_or(900), organization_list: resp.organization_list,
})
}
pub async fn validate_copilot_token(&self, token: &str) -> Result<bool> {
let client = reqwest::Client::new();
let response = client
.get("https://api.githubcopilot.com/models")
.header("Authorization", format!("Bearer {}", token))
.header("Editor-Version", "vscode/1.85.0")
.header("Editor-Plugin-Version", "copilot/1.155.0")
.timeout(std::time::Duration::from_secs(10))
.send()
.await?;
Ok(response.status().is_success())
}
pub async fn get_valid_copilot_token(&self) -> Result<String> {
if let Ok(copilot_token) = self.load_copilot_token().await {
if !self.needs_refresh(&copilot_token) {
debug!("Validating cached Copilot token with API call...");
if self
.validate_copilot_token(&copilot_token.token)
.await
.unwrap_or(false)
{
debug!("Cached Copilot token is valid");
return Ok(copilot_token.token);
}
debug!("Cached Copilot token failed validation, will refresh");
} else {
debug!(
"Copilot token expired or expiring soon (within {}s), refreshing...",
TOKEN_REFRESH_BUFFER
);
}
} else {
debug!("No cached Copilot token found, fetching fresh token");
}
let github_token = self.load_github_token().await?;
debug!("Requesting fresh Copilot token from GitHub API");
let copilot_token = self.fetch_copilot_token(&github_token.access_token).await?;
let token_value = copilot_token.token.clone();
if !self
.validate_copilot_token(&token_value)
.await
.unwrap_or(false)
{
return Err(anyhow::anyhow!(
"Fetched token is invalid. Your GitHub account may not have Copilot access."
));
}
self.save_copilot_token(copilot_token.clone()).await?;
debug!(
"Successfully refreshed and saved Copilot token (expires at: {})",
copilot_token.expires_at
);
Ok(token_value)
}
pub async fn clear_tokens(&self) -> Result<()> {
let _ = fs::remove_file(self.github_token_path()).await;
let _ = fs::remove_file(self.copilot_token_path()).await;
Ok(())
}
pub async fn has_github_token(&self) -> bool {
self.github_token_path().exists()
}
pub async fn has_copilot_token(&self) -> bool {
self.copilot_token_path().exists()
}
pub async fn try_load_vscode_github_token(&self) -> Option<String> {
let mut candidates = Vec::new();
if let Some(dir) = dirs::config_dir() {
candidates.push(dir.join("github-copilot").join("hosts.json"));
}
if let Some(home) = dirs::home_dir() {
candidates.push(
home.join(".config")
.join("github-copilot")
.join("hosts.json"),
);
candidates.push(
home.join("Library")
.join("Application Support")
.join("github-copilot")
.join("hosts.json"),
);
}
let vscode_hosts_path = candidates.into_iter().find(|p| p.exists())?;
let contents = fs::read_to_string(&vscode_hosts_path).await.ok()?;
#[derive(Deserialize)]
struct HostsJson {
#[serde(rename = "github.com")]
github_com: Option<GithubComEntry>,
}
#[derive(Deserialize)]
struct GithubComEntry {
oauth_token: String,
}
let hosts: HostsJson = serde_json::from_str(&contents).ok()?;
Some(hosts.github_com?.oauth_token)
}
pub async fn import_vscode_token(&self) -> Result<bool> {
if let Some(token) = self.try_load_vscode_github_token().await {
self.save_github_token(token).await?;
debug!("Successfully imported GitHub token from VS Code Copilot");
Ok(true)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_token_manager_creation() {
let manager = TokenManager::new().unwrap();
assert!(manager.config_dir.to_string_lossy().contains("edgequake"));
}
#[tokio::test]
async fn test_config_dir_creation() {
let manager = TokenManager::new().unwrap();
manager.ensure_config_dir().await.unwrap();
assert!(manager.config_dir.exists());
}
#[test]
fn test_needs_refresh_false_when_valid() {
let manager = TokenManager::new().unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let token = CopilotToken {
token: "test_token".to_string(),
expires_at: now + 7200, refresh_in: 900,
organization_list: None,
};
assert!(!manager.needs_refresh(&token));
}
#[test]
fn test_needs_refresh_true_when_expired() {
let manager = TokenManager::new().unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let token = CopilotToken {
token: "test_token".to_string(),
expires_at: now.saturating_sub(3600), refresh_in: 900,
organization_list: None,
};
assert!(manager.needs_refresh(&token));
}
#[test]
fn test_needs_refresh_true_within_buffer() {
let manager = TokenManager::new().unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let token = CopilotToken {
token: "test_token".to_string(),
expires_at: now + 30, refresh_in: 900,
organization_list: None,
};
assert!(manager.needs_refresh(&token));
}
#[test]
fn test_needs_refresh_false_just_outside_buffer() {
let manager = TokenManager::new().unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let token = CopilotToken {
token: "test_token".to_string(),
expires_at: now + 120, refresh_in: 900,
organization_list: None,
};
assert!(!manager.needs_refresh(&token));
}
#[test]
fn test_github_token_serialization_roundtrip() {
let token = GitHubToken {
access_token: "gho_test_token_12345".to_string(),
created_at: 1699876543,
};
let json = serde_json::to_string(&token).unwrap();
let parsed: GitHubToken = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.access_token, token.access_token);
assert_eq!(parsed.created_at, token.created_at);
}
#[test]
fn test_copilot_token_serialization_roundtrip() {
let token = CopilotToken {
token: "tid=test;exp=1234567890;sku=copilot".to_string(),
expires_at: 1699876543,
refresh_in: 900,
organization_list: Some(vec!["org1".to_string(), "org2".to_string()]),
};
let json = serde_json::to_string(&token).unwrap();
let parsed: CopilotToken = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.token, token.token);
assert_eq!(parsed.expires_at, token.expires_at);
assert_eq!(parsed.refresh_in, token.refresh_in);
assert_eq!(parsed.organization_list, token.organization_list);
}
#[test]
fn test_copilot_token_without_org_list() {
let token = CopilotToken {
token: "test_token".to_string(),
expires_at: 1699876543,
refresh_in: 900,
organization_list: None,
};
let json = serde_json::to_string(&token).unwrap();
let parsed: CopilotToken = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.organization_list, None);
}
#[test]
fn test_github_token_json_format() {
let token = GitHubToken {
access_token: "test123".to_string(),
created_at: 1699876543,
};
let json = serde_json::to_string(&token).unwrap();
assert!(json.contains("access_token"));
assert!(json.contains("test123"));
assert!(json.contains("created_at"));
assert!(json.contains("1699876543"));
}
#[test]
fn test_token_paths_are_distinct() {
let manager = TokenManager::new().unwrap();
let github_path = manager.github_token_path();
let copilot_path = manager.copilot_token_path();
assert_ne!(github_path, copilot_path);
assert!(github_path.to_string_lossy().contains("github"));
assert!(copilot_path.to_string_lossy().contains("copilot"));
}
#[test]
fn test_paths_under_config_dir() {
let manager = TokenManager::new().unwrap();
let github_path = manager.github_token_path();
let copilot_path = manager.copilot_token_path();
assert!(github_path.starts_with(&manager.config_dir));
assert!(copilot_path.starts_with(&manager.config_dir));
}
}