code_mesh_core/auth/
github_copilot.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use super::{Auth, AuthCredentials, AuthStorage};
6
7/// GitHub Copilot authentication implementation
8pub 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    /// Start device code flow for GitHub authentication
66    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    /// Poll for GitHub access token during device flow
99    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    /// Exchange GitHub OAuth token for Copilot API token
136    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()), // Store GitHub token for refresh
165            expires_at: Some(token_response.expires_at),
166        })
167    }
168    
169    /// Get valid Copilot access token, refreshing if necessary
170    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                // Check if token is expired
179                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                        // Token expired, try to refresh using GitHub token
187                        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                                    // Refresh failed, remove credentials
197                                    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                // Ensure credentials are fresh
224                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), // GitHub OAuth token
260    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}