use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time::sleep;
pub const COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
const USER_AGENT: &str = "GithubCopilot/1.155.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopilotOAuthToken {
pub access_token: String,
pub token_type: String,
}
impl CopilotOAuthToken {
pub fn default_path() -> Result<PathBuf> {
let base = dirs::config_dir().context("Could not determine config directory")?;
Ok(base.join("xcode").join("copilot_auth.json"))
}
pub fn load() -> Result<Option<Self>> {
let path = Self::default_path()?;
if !path.exists() {
return Ok(None);
}
let content =
std::fs::read_to_string(&path).with_context(|| format!("Failed to read {:?}", path))?;
let token: Self =
serde_json::from_str(&content).context("Failed to parse copilot_auth.json")?;
Ok(Some(token))
}
pub fn save(&self) -> Result<()> {
let path = Self::default_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json).with_context(|| format!("Failed to write {:?}", path))?;
Ok(())
}
pub fn delete() -> Result<()> {
let path = Self::default_path()?;
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CopilotApiToken {
pub token: String,
pub expires_at: u64,
}
impl CopilotApiToken {
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now + 60 >= self.expires_at
}
}
#[derive(Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: u64,
}
#[derive(Deserialize)]
struct AccessTokenResponse {
access_token: Option<String>,
token_type: Option<String>,
error: Option<String>,
}
pub async fn device_code_flow(client: &reqwest::Client) -> Result<CopilotOAuthToken> {
let resp: DeviceCodeResponse = client
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.header("User-Agent", USER_AGENT)
.json(&serde_json::json!({
"client_id": COPILOT_CLIENT_ID,
"scope": "read:user"
}))
.send()
.await
.context("Failed to request device code")?
.json()
.await
.context("Failed to parse device code response")?;
println!("\n┌─────────────────────────────────────────────────────────┐");
println!("│ GitHub Copilot — Device Authorization │");
println!("├─────────────────────────────────────────────────────────┤");
println!("│ │");
println!("│ 1. Visit: {:<47}│", resp.verification_uri);
println!("│ 2. Enter code: {:<40}│", resp.user_code);
println!("│ │");
println!(
"│ Waiting for authorization... (expires in {}s) │",
resp.expires_in
);
println!("└─────────────────────────────────────────────────────────┘\n");
let poll_interval = Duration::from_secs(resp.interval.max(5));
let deadline = SystemTime::now() + Duration::from_secs(resp.expires_in);
loop {
if SystemTime::now() > deadline {
bail!("Device authorization timed out. Please try :login again.");
}
sleep(poll_interval).await;
let token_resp: AccessTokenResponse = client
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.header("User-Agent", USER_AGENT)
.json(&serde_json::json!({
"client_id": COPILOT_CLIENT_ID,
"device_code": resp.device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
}))
.send()
.await
.context("Failed to poll access token")?
.json()
.await
.context("Failed to parse access token response")?;
match token_resp.error.as_deref() {
None | Some("") => {
}
Some("authorization_pending") => {
print!(".");
std::io::Write::flush(&mut std::io::stdout()).ok();
continue;
}
Some("slow_down") => {
sleep(Duration::from_secs(5)).await;
continue;
}
Some("expired_token") => {
bail!("Device code expired. Please run :login again.");
}
Some("access_denied") => {
bail!("Authorization was denied by the user.");
}
Some(other) => {
bail!("OAuth error: {}", other);
}
}
if let Some(access_token) = token_resp.access_token {
println!("\n✓ Authorized! Fetching Copilot API token...");
return Ok(CopilotOAuthToken {
access_token,
token_type: token_resp
.token_type
.unwrap_or_else(|| "bearer".to_string()),
});
}
}
}
#[derive(Deserialize)]
struct CopilotTokenResponse {
token: String,
expires_at: f64,
}
pub async fn refresh_copilot_token(
client: &reqwest::Client,
oauth_token: &str,
) -> Result<CopilotApiToken> {
let bytes = client
.get("https://api.github.com/copilot_internal/v2/token")
.header("Authorization", format!("token {}", oauth_token))
.header("User-Agent", USER_AGENT)
.send()
.await
.context("Failed to refresh Copilot token")?
.bytes()
.await
.context("Failed to read Copilot token response body")?;
match serde_json::from_slice::<CopilotTokenResponse>(&bytes) {
Ok(resp) => Ok(CopilotApiToken {
token: resp.token,
expires_at: resp.expires_at as u64,
}),
Err(_) => {
let msg = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v["message"].as_str().map(|s| s.to_string()))
.unwrap_or_else(|| {
String::from_utf8_lossy(&bytes)
.chars()
.take(120)
.collect::<String>()
});
bail!(
"Copilot token refresh failed: {}\n\nYour saved credentials may be expired or revoked.\nRun /login to re-authenticate.",
msg
);
}
}
}