Skip to main content

dot/auth/
copilot.rs

1use anyhow::{Context, Result, bail};
2use crossterm::{
3    execute,
4    style::{Color, Print, ResetColor, SetForegroundColor},
5};
6use serde::Deserialize;
7use std::io::{self, Write};
8use std::path::PathBuf;
9
10use super::ProviderCredential;
11
12const COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
13const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
14const TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
15
16#[derive(Debug, Deserialize)]
17struct DeviceCodeResponse {
18    device_code: String,
19    user_code: String,
20    verification_uri: String,
21    #[allow(dead_code)]
22    expires_in: u64,
23    interval: u64,
24}
25
26#[derive(Debug, Deserialize)]
27struct TokenResponse {
28    #[serde(default)]
29    access_token: String,
30    #[serde(default)]
31    error: String,
32    #[serde(default)]
33    interval: Option<u64>,
34}
35
36fn apps_json_path() -> PathBuf {
37    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
38        && !xdg.is_empty()
39    {
40        return PathBuf::from(xdg).join("github-copilot").join("apps.json");
41    }
42    dirs::home_dir()
43        .unwrap_or_else(|| PathBuf::from("."))
44        .join(".config")
45        .join("github-copilot")
46        .join("apps.json")
47}
48
49/// Try to read an existing Copilot OAuth token from ~/.config/github-copilot/apps.json
50pub fn read_existing_token() -> Option<String> {
51    let path = apps_json_path();
52    let content = std::fs::read_to_string(&path).ok()?;
53    let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
54    let obj = parsed.as_object()?;
55    obj.iter().find_map(|(key, value)| {
56        if key.starts_with("github.com") {
57            value["oauth_token"].as_str().map(|v| v.to_string())
58        } else {
59            None
60        }
61    })
62}
63
64fn save_to_apps_json(token: &str) -> Result<()> {
65    let path = apps_json_path();
66    if let Some(parent) = path.parent() {
67        std::fs::create_dir_all(parent)
68            .with_context(|| format!("creating {}", parent.display()))?;
69    }
70
71    let key = format!("github.com:{}", COPILOT_CLIENT_ID);
72    let existing: serde_json::Value = std::fs::read_to_string(&path)
73        .ok()
74        .and_then(|c| serde_json::from_str(&c).ok())
75        .unwrap_or_else(|| serde_json::json!({}));
76
77    let mut obj = existing
78        .as_object()
79        .cloned()
80        .unwrap_or_else(serde_json::Map::new);
81    obj.insert(
82        key,
83        serde_json::json!({
84            "oauth_token": token,
85            "githubAppId": COPILOT_CLIENT_ID,
86        }),
87    );
88
89    std::fs::write(&path, serde_json::to_string_pretty(&obj)?)
90        .with_context(|| format!("writing {}", path.display()))
91}
92
93pub(super) async fn device_flow() -> Result<ProviderCredential> {
94    let mut stdout = io::stdout();
95
96    let client = reqwest::Client::new();
97    let resp = client
98        .post(DEVICE_CODE_URL)
99        .header("Accept", "application/json")
100        .form(&[("client_id", COPILOT_CLIENT_ID), ("scope", "read:user")])
101        .send()
102        .await
103        .context("requesting device code")?;
104
105    if !resp.status().is_success() {
106        let status = resp.status();
107        let body = resp.text().await.unwrap_or_default();
108        bail!("device code request failed ({}): {}", status, body);
109    }
110
111    let device: DeviceCodeResponse = resp.json().await.context("parsing device code response")?;
112
113    execute!(
114        stdout,
115        Print("\r\n"),
116        SetForegroundColor(Color::Yellow),
117        Print("  Enter this code at GitHub:\r\n\r\n"),
118        ResetColor,
119        SetForegroundColor(Color::White),
120        Print(format!("    {}\r\n\r\n", device.user_code)),
121        ResetColor,
122        SetForegroundColor(Color::DarkGrey),
123        Print(format!("  URL: {}\r\n\r\n", device.verification_uri)),
124        ResetColor,
125        SetForegroundColor(Color::Yellow),
126        Print("  Waiting for authorization...\r\n"),
127        ResetColor,
128    )?;
129    stdout.flush()?;
130
131    if let Err(e) = open::that(&device.verification_uri) {
132        execute!(
133            stdout,
134            SetForegroundColor(Color::Red),
135            Print(format!("  Could not open browser: {}\r\n", e)),
136            ResetColor,
137        )?;
138    }
139
140    let mut interval = device.interval;
141    loop {
142        tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
143
144        let resp = client
145            .post(TOKEN_URL)
146            .header("Accept", "application/json")
147            .form(&[
148                ("client_id", COPILOT_CLIENT_ID),
149                ("device_code", &device.device_code),
150                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
151            ])
152            .send()
153            .await
154            .context("polling for token")?;
155
156        let token: TokenResponse = resp.json().await.context("parsing token response")?;
157
158        if !token.access_token.is_empty() {
159            let _ = save_to_apps_json(&token.access_token);
160            return Ok(ProviderCredential::OAuth {
161                access_token: token.access_token,
162                refresh_token: None,
163                expires_at: None,
164                api_key: None,
165            });
166        }
167
168        match token.error.as_str() {
169            "authorization_pending" => continue,
170            "slow_down" => {
171                interval = token.interval.unwrap_or(interval + 5);
172                continue;
173            }
174            "expired_token" => bail!("device code expired — please try again"),
175            "access_denied" => bail!("authorization was denied"),
176            other => bail!("token exchange error: {}", other),
177        }
178    }
179}
180
181pub async fn copilot_login() -> Result<ProviderCredential> {
182    let mut stdout = io::stdout();
183
184    if let Some(token) = read_existing_token() {
185        execute!(
186            stdout,
187            Print("\r\n"),
188            SetForegroundColor(Color::Green),
189            Print("  Found existing Copilot token.\r\n"),
190            ResetColor,
191        )?;
192        stdout.flush()?;
193        return Ok(ProviderCredential::OAuth {
194            access_token: token,
195            refresh_token: None,
196            expires_at: None,
197            api_key: None,
198        });
199    }
200
201    device_flow().await
202}