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
49pub 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}