1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use super::{Auth, AuthCredentials, AuthStorage};
6
7pub struct GitHubCopilotAuth {
9 storage: Box<dyn AuthStorage>,
10}
11
12#[derive(Debug, Serialize)]
13struct DeviceCodeRequest {
14 client_id: String,
15 scope: String,
16}
17
18#[derive(Debug, Deserialize)]
19struct DeviceCodeResponse {
20 device_code: String,
21 user_code: String,
22 verification_uri: String,
23 expires_in: u32,
24 interval: u32,
25}
26
27#[derive(Debug, Serialize)]
28struct AccessTokenRequest {
29 client_id: String,
30 device_code: String,
31 grant_type: String,
32}
33
34#[derive(Debug, Deserialize)]
35struct AccessTokenResponse {
36 access_token: Option<String>,
37 error: Option<String>,
38 error_description: Option<String>,
39}
40
41#[derive(Debug, Deserialize)]
42struct CopilotTokenResponse {
43 token: String,
44 expires_at: u64,
45 refresh_in: u64,
46 endpoints: CopilotEndpoints,
47}
48
49#[derive(Debug, Deserialize)]
50struct CopilotEndpoints {
51 api: String,
52}
53
54impl GitHubCopilotAuth {
55 const CLIENT_ID: &'static str = "Iv1.b507a08c87ecfe98";
56 const PROVIDER_ID: &'static str = "github-copilot";
57 const DEVICE_CODE_URL: &'static str = "https://github.com/login/device/code";
58 const ACCESS_TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
59 const COPILOT_TOKEN_URL: &'static str = "https://api.github.com/copilot_internal/v2/token";
60
61 pub fn new(storage: Box<dyn AuthStorage>) -> Self {
62 Self { storage }
63 }
64
65 pub async fn start_device_flow() -> crate::Result<DeviceCodeResponse> {
67 let client = reqwest::Client::new();
68 let request = DeviceCodeRequest {
69 client_id: Self::CLIENT_ID.to_string(),
70 scope: "read:user".to_string(),
71 };
72
73 let response = client
74 .post(Self::DEVICE_CODE_URL)
75 .header("Accept", "application/json")
76 .header("Content-Type", "application/json")
77 .header("User-Agent", "GitHubCopilotChat/0.26.7")
78 .json(&request)
79 .send()
80 .await
81 .map_err(|e| crate::Error::Other(anyhow::anyhow!("Device code request failed: {}", e)))?;
82
83 if !response.status().is_success() {
84 return Err(crate::Error::Other(anyhow::anyhow!(
85 "Device code request failed with status: {}",
86 response.status()
87 )));
88 }
89
90 let device_response: DeviceCodeResponse = response
91 .json()
92 .await
93 .map_err(|e| crate::Error::Other(anyhow::anyhow!("Failed to parse device code response: {}", e)))?;
94
95 Ok(device_response)
96 }
97
98 pub async fn poll_for_token(device_code: &str) -> crate::Result<GitHubCopilotAuthResult> {
100 let client = reqwest::Client::new();
101 let request = AccessTokenRequest {
102 client_id: Self::CLIENT_ID.to_string(),
103 device_code: device_code.to_string(),
104 grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
105 };
106
107 let response = client
108 .post(Self::ACCESS_TOKEN_URL)
109 .header("Accept", "application/json")
110 .header("Content-Type", "application/json")
111 .header("User-Agent", "GitHubCopilotChat/0.26.7")
112 .json(&request)
113 .send()
114 .await
115 .map_err(|e| crate::Error::Other(anyhow::anyhow!("Token poll request failed: {}", e)))?;
116
117 if !response.status().is_success() {
118 return Ok(GitHubCopilotAuthResult::Failed);
119 }
120
121 let token_response: AccessTokenResponse = response
122 .json()
123 .await
124 .map_err(|e| crate::Error::Other(anyhow::anyhow!("Failed to parse token response: {}", e)))?;
125
126 if let Some(access_token) = token_response.access_token {
127 Ok(GitHubCopilotAuthResult::Complete(access_token))
128 } else if token_response.error.as_deref() == Some("authorization_pending") {
129 Ok(GitHubCopilotAuthResult::Pending)
130 } else {
131 Ok(GitHubCopilotAuthResult::Failed)
132 }
133 }
134
135 pub async fn get_copilot_token(github_token: &str) -> crate::Result<AuthCredentials> {
137 let client = reqwest::Client::new();
138
139 let response = client
140 .get(Self::COPILOT_TOKEN_URL)
141 .header("Accept", "application/json")
142 .header("Authorization", format!("Bearer {}", github_token))
143 .header("User-Agent", "GitHubCopilotChat/0.26.7")
144 .header("Editor-Version", "vscode/1.99.3")
145 .header("Editor-Plugin-Version", "copilot-chat/0.26.7")
146 .send()
147 .await
148 .map_err(|e| crate::Error::Other(anyhow::anyhow!("Copilot token request failed: {}", e)))?;
149
150 if !response.status().is_success() {
151 return Err(crate::Error::Other(anyhow::anyhow!(
152 "Copilot token request failed with status: {}",
153 response.status()
154 )));
155 }
156
157 let token_response: CopilotTokenResponse = response
158 .json()
159 .await
160 .map_err(|e| crate::Error::Other(anyhow::anyhow!("Failed to parse copilot token response: {}", e)))?;
161
162 Ok(AuthCredentials::OAuth {
163 access_token: token_response.token,
164 refresh_token: Some(github_token.to_string()), expires_at: Some(token_response.expires_at),
166 })
167 }
168
169 pub async fn get_access_token(&self) -> crate::Result<Option<String>> {
171 let credentials = match self.storage.get(Self::PROVIDER_ID).await? {
172 Some(creds) => creds,
173 None => return Ok(None),
174 };
175
176 match credentials {
177 AuthCredentials::OAuth { access_token, refresh_token, expires_at } => {
178 if let Some(exp) = expires_at {
180 let now = SystemTime::now()
181 .duration_since(UNIX_EPOCH)
182 .unwrap()
183 .as_secs();
184
185 if now >= exp {
186 if let Some(github_token) = refresh_token {
188 match Self::get_copilot_token(&github_token).await {
189 Ok(new_creds) => {
190 self.storage.set(Self::PROVIDER_ID, new_creds.clone()).await?;
191 if let AuthCredentials::OAuth { access_token, .. } = new_creds {
192 return Ok(Some(access_token));
193 }
194 }
195 Err(_) => {
196 self.storage.remove(Self::PROVIDER_ID).await?;
198 return Ok(None);
199 }
200 }
201 } else {
202 return Ok(None);
203 }
204 }
205 }
206
207 Ok(Some(access_token))
208 }
209 _ => Ok(None),
210 }
211 }
212}
213
214#[async_trait]
215impl Auth for GitHubCopilotAuth {
216 fn provider_id(&self) -> &str {
217 Self::PROVIDER_ID
218 }
219
220 async fn get_credentials(&self) -> crate::Result<AuthCredentials> {
221 match self.storage.get(Self::PROVIDER_ID).await? {
222 Some(creds) => {
223 if creds.is_expired() {
225 match &creds {
226 AuthCredentials::OAuth { refresh_token: Some(github_token), .. } => {
227 let new_creds = Self::get_copilot_token(github_token).await?;
228 self.storage.set(Self::PROVIDER_ID, new_creds.clone()).await?;
229 Ok(new_creds)
230 }
231 _ => Err(crate::Error::Other(anyhow::anyhow!("Credentials expired and cannot be refreshed"))),
232 }
233 } else {
234 Ok(creds)
235 }
236 }
237 None => Err(crate::Error::Other(anyhow::anyhow!("No credentials found for GitHub Copilot"))),
238 }
239 }
240
241 async fn set_credentials(&self, credentials: AuthCredentials) -> crate::Result<()> {
242 self.storage.set(Self::PROVIDER_ID, credentials).await
243 }
244
245 async fn remove_credentials(&self) -> crate::Result<()> {
246 self.storage.remove(Self::PROVIDER_ID).await
247 }
248
249 async fn has_credentials(&self) -> bool {
250 self.storage.get(Self::PROVIDER_ID).await
251 .map(|opt| opt.is_some())
252 .unwrap_or(false)
253 }
254}
255
256#[derive(Debug, Clone)]
257pub enum GitHubCopilotAuthResult {
258 Pending,
259 Complete(String), Failed,
261}
262
263#[derive(Debug, thiserror::Error)]
264pub enum GitHubCopilotError {
265 #[error("Device code flow failed")]
266 DeviceCodeFailed,
267
268 #[error("Token exchange failed")]
269 TokenExchangeFailed,
270
271 #[error("Authentication expired")]
272 AuthenticationExpired,
273
274 #[error("Copilot token request failed")]
275 CopilotTokenFailed,
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_constants() {
284 assert_eq!(GitHubCopilotAuth::CLIENT_ID, "Iv1.b507a08c87ecfe98");
285 assert_eq!(GitHubCopilotAuth::PROVIDER_ID, "github-copilot");
286 assert!(GitHubCopilotAuth::DEVICE_CODE_URL.contains("github.com"));
287 }
288
289 #[test]
290 fn test_auth_result() {
291 match GitHubCopilotAuthResult::Pending {
292 GitHubCopilotAuthResult::Pending => (),
293 _ => panic!("Expected Pending"),
294 }
295
296 match GitHubCopilotAuthResult::Complete("token".to_string()) {
297 GitHubCopilotAuthResult::Complete(token) => assert_eq!(token, "token"),
298 _ => panic!("Expected Complete"),
299 }
300 }
301}