Skip to main content

litellm_rs/core/providers/github_copilot/
authenticator.rs

1//! GitHub Copilot OAuth Device Flow Authenticator
2//!
3//! Handles GitHub Copilot authentication using the OAuth Device Flow.
4//! Manages access tokens and API keys with automatic refresh.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use tracing::{debug, warn};
15
16use super::config::GitHubCopilotConfig;
17use super::error::GitHubCopilotError;
18use crate::core::providers::unified_provider::ProviderError;
19
20/// GitHub OAuth client ID for Copilot
21const GITHUB_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
22
23/// GitHub device code URL
24const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
25
26/// GitHub access token URL
27const GITHUB_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
28
29/// GitHub Copilot API key URL
30const GITHUB_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token";
31
32/// API key information stored in the cache
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ApiKeyInfo {
35    /// The API token
36    pub token: String,
37    /// Expiration timestamp (Unix timestamp)
38    pub expires_at: u64,
39    /// API endpoints
40    #[serde(default)]
41    pub endpoints: Endpoints,
42}
43
44/// API endpoints returned by GitHub
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct Endpoints {
47    /// API endpoint URL
48    pub api: Option<String>,
49}
50
51/// Device code response from GitHub
52#[derive(Debug, Deserialize)]
53struct DeviceCodeResponse {
54    device_code: String,
55    user_code: String,
56    verification_uri: String,
57    #[serde(default)]
58    interval: u64,
59}
60
61/// Access token response from GitHub
62#[derive(Debug, Deserialize)]
63struct AccessTokenResponse {
64    access_token: Option<String>,
65    error: Option<String>,
66}
67
68/// GitHub Copilot OAuth authenticator
69#[derive(Clone)]
70pub struct CopilotAuthenticator {
71    /// Token directory path
72    token_dir: PathBuf,
73    /// Access token file path
74    access_token_path: PathBuf,
75    /// API key file path
76    api_key_path: PathBuf,
77    access_token: Arc<RwLock<Option<String>>>,
78    api_key: Arc<RwLock<Option<ApiKeyInfo>>>,
79    credential_files: Arc<RwLock<[Option<Vec<u8>>; 2]>>,
80    credential_identity: [u8; 32],
81}
82
83impl std::fmt::Debug for CopilotAuthenticator {
84    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        formatter
86            .debug_struct("CopilotAuthenticator")
87            .field("credentials", &"[REDACTED]")
88            .finish_non_exhaustive()
89    }
90}
91
92impl CopilotAuthenticator {
93    /// Create a new authenticator from configuration
94    pub fn new(config: &GitHubCopilotConfig) -> Self {
95        let token_dir = PathBuf::from(config.get_token_dir());
96        let access_token_path = token_dir.join(config.get_access_token_file());
97        let api_key_path = token_dir.join(config.get_api_key_file());
98        // Capture the same file-backed authority used by this resource. External
99        // account rotation takes effect on reconstruction, not on an old lease.
100        let access_file = fs::read(&access_token_path).ok();
101        let api_key_file = fs::read(&api_key_path).ok();
102        let access_token = access_file
103            .as_ref()
104            .and_then(|bytes| std::str::from_utf8(bytes).ok())
105            .map(|token| token.trim().to_string())
106            .filter(|token| !token.is_empty());
107        let api_key = api_key_file
108            .as_ref()
109            .and_then(|bytes| serde_json::from_slice::<ApiKeyInfo>(bytes).ok());
110        let mut identity = serde_json::json!({
111            "token_dir": token_dir,
112            "access_token_path": access_token_path,
113            "api_key_path": api_key_path,
114            "access_token": access_token,
115            "api_key": api_key,
116        });
117        identity.sort_all_objects();
118        let credential_identity = Sha256::digest(identity.to_string().as_bytes()).into();
119
120        Self {
121            token_dir,
122            access_token_path,
123            api_key_path,
124            access_token: Arc::new(RwLock::new(access_token)),
125            api_key: Arc::new(RwLock::new(api_key)),
126            credential_files: Arc::new(RwLock::new([access_file, api_key_file])),
127            credential_identity,
128        }
129    }
130
131    pub(super) fn credential_resource_identity(&self) -> [u8; 32] {
132        self.credential_identity
133    }
134
135    fn persist_credential(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
136        let mut files = self.credential_files.write();
137        let paths = [&self.access_token_path, &self.api_key_path];
138        for (captured_path, captured) in paths.iter().zip(files.iter()) {
139            let changed = match fs::read(captured_path) {
140                Ok(bytes) => captured.as_ref() != Some(&bytes),
141                Err(error) if error.kind() == std::io::ErrorKind::NotFound => captured.is_some(),
142                Err(error) => return Err(error),
143            };
144            if changed {
145                // Detected external rotation owns these files. Keep this resource's
146                // refresh in memory; this check is not an inter-process atomic write.
147                debug!("Copilot credential files changed; keeping refreshed credentials in memory");
148                return Ok(());
149            }
150        }
151        fs::write(path, contents)?;
152        for (captured_path, captured) in paths.iter().zip(files.iter_mut()) {
153            if captured_path.as_path() == path {
154                *captured = Some(contents.to_vec());
155            }
156        }
157        Ok(())
158    }
159
160    /// Ensure the token directory exists
161    fn ensure_token_dir(&self) -> Result<(), GitHubCopilotError> {
162        if !self.token_dir.exists() {
163            fs::create_dir_all(&self.token_dir).map_err(|e| {
164                ProviderError::configuration(
165                    "github_copilot",
166                    format!("Failed to create token directory: {}", e),
167                )
168            })?;
169        }
170        Ok(())
171    }
172
173    /// Get the access token, performing device flow authentication if needed
174    pub async fn get_access_token(&self) -> Result<String, GitHubCopilotError> {
175        if let Some(token) = self.access_token.read().clone() {
176            return Ok(token);
177        }
178
179        // Need to perform device flow authentication
180        warn!("No cached access token found, need to authenticate");
181
182        // Retry up to 3 times
183        for attempt in 1..=3 {
184            debug!("Access token acquisition attempt {}/3", attempt);
185            match self.perform_device_flow().await {
186                Ok(token) => {
187                    // Save to cache
188                    self.ensure_token_dir()?;
189                    *self.access_token.write() = Some(token.clone());
190                    if let Err(e) =
191                        self.persist_credential(&self.access_token_path, token.as_bytes())
192                    {
193                        warn!("Failed to cache access token: {}", e);
194                    }
195                    return Ok(token);
196                }
197                Err(e) => {
198                    warn!("Device flow attempt {} failed: {}", attempt, e);
199                    if attempt == 3 {
200                        return Err(ProviderError::authentication(
201                            "github_copilot",
202                            "Access token error: Failed to get access token after 3 attempts",
203                        ));
204                    }
205                }
206            }
207        }
208
209        Err(ProviderError::authentication(
210            "github_copilot",
211            "Access token error: Failed to get access token",
212        ))
213    }
214
215    /// Get the API key, refreshing if needed
216    pub async fn get_api_key(&self) -> Result<String, GitHubCopilotError> {
217        if let Some(api_key_info) = self.api_key.read().clone() {
218            // Check if not expired
219            let now = SystemTime::now()
220                .duration_since(UNIX_EPOCH)
221                .unwrap_or(Duration::from_secs(0))
222                .as_secs();
223
224            if api_key_info.expires_at > now {
225                return Ok(api_key_info.token);
226            }
227            debug!("API key expired, refreshing...");
228        }
229
230        // Need to refresh
231        self.refresh_api_key().await
232    }
233
234    /// Get the API base URL from cached API key info
235    pub fn get_api_base(&self) -> Option<String> {
236        self.api_key
237            .read()
238            .as_ref()
239            .and_then(|info| info.endpoints.api.clone())
240    }
241
242    /// Refresh the API key using the access token
243    async fn refresh_api_key(&self) -> Result<String, GitHubCopilotError> {
244        let access_token = self.get_access_token().await?;
245        let headers = self.get_github_headers(Some(&access_token));
246
247        let client = crate::core::http::outbound::default_outbound_client().clone();
248
249        for attempt in 1..=3 {
250            let response = client
251                .get(GITHUB_API_KEY_URL)
252                .headers(headers.clone())
253                .send()
254                .await
255                .map_err(|e| {
256                    ProviderError::authentication(
257                        "github_copilot",
258                        format!("Refresh error: HTTP error: {}", e),
259                    )
260                })?;
261
262            if !response.status().is_success() {
263                warn!(
264                    "API key refresh attempt {}/3 failed with status: {}",
265                    attempt,
266                    response.status()
267                );
268                if attempt == 3 {
269                    return Err(ProviderError::authentication(
270                        "github_copilot",
271                        "Refresh error: Failed to refresh API key after 3 attempts",
272                    ));
273                }
274                continue;
275            }
276
277            let api_key_info: ApiKeyInfo = response.json().await.map_err(|e| {
278                ProviderError::authentication(
279                    "github_copilot",
280                    format!("Refresh error: Failed to parse response: {}", e),
281                )
282            })?;
283
284            // Save to cache
285            self.ensure_token_dir()?;
286            *self.api_key.write() = Some(api_key_info.clone());
287            if let Ok(json) = serde_json::to_string(&api_key_info)
288                && let Err(e) = self.persist_credential(&self.api_key_path, json.as_bytes())
289            {
290                warn!("Failed to cache API key: {}", e);
291            }
292
293            return Ok(api_key_info.token);
294        }
295
296        Err(ProviderError::authentication(
297            "github_copilot",
298            "Refresh error: Failed to refresh API key",
299        ))
300    }
301
302    /// Perform the OAuth device flow
303    async fn perform_device_flow(&self) -> Result<String, GitHubCopilotError> {
304        let client = crate::core::http::outbound::default_outbound_client().clone();
305        let headers = self.get_github_headers(None);
306
307        // Step 1: Get device code
308        let response = client
309            .post(GITHUB_DEVICE_CODE_URL)
310            .headers(headers.clone())
311            .json(&serde_json::json!({
312                "client_id": GITHUB_CLIENT_ID,
313                "scope": "read:user"
314            }))
315            .send()
316            .await
317            .map_err(|e| {
318                ProviderError::authentication(
319                    "github_copilot",
320                    format!("Device code error: HTTP error: {}", e),
321                )
322            })?;
323
324        if !response.status().is_success() {
325            return Err(ProviderError::authentication(
326                "github_copilot",
327                format!(
328                    "Device code error: Failed to get device code: {}",
329                    response.status()
330                ),
331            ));
332        }
333
334        let device_code_response: DeviceCodeResponse = response.json().await.map_err(|e| {
335            ProviderError::authentication(
336                "github_copilot",
337                format!("Device code error: Failed to parse response: {}", e),
338            )
339        })?;
340
341        // Print user instructions
342        println!(
343            "\nPlease visit {} and enter code {} to authenticate.\n",
344            device_code_response.verification_uri, device_code_response.user_code
345        );
346
347        // Step 2: Poll for access token
348        let interval = if device_code_response.interval > 0 {
349            device_code_response.interval
350        } else {
351            5
352        };
353
354        let max_attempts = 60 / interval as usize; // 1 minute max
355
356        for _attempt in 0..max_attempts {
357            tokio::time::sleep(Duration::from_secs(interval)).await;
358
359            let response = client
360                .post(GITHUB_ACCESS_TOKEN_URL)
361                .headers(headers.clone())
362                .json(&serde_json::json!({
363                    "client_id": GITHUB_CLIENT_ID,
364                    "device_code": device_code_response.device_code,
365                    "grant_type": "urn:ietf:params:oauth:grant-type:device_code"
366                }))
367                .send()
368                .await
369                .map_err(|e| {
370                    ProviderError::authentication(
371                        "github_copilot",
372                        format!("Access token error: HTTP error: {}", e),
373                    )
374                })?;
375
376            let token_response: AccessTokenResponse = response.json().await.map_err(|e| {
377                ProviderError::authentication(
378                    "github_copilot",
379                    format!("Access token error: Failed to parse response: {}", e),
380                )
381            })?;
382
383            if let Some(access_token) = token_response.access_token {
384                debug!("Authentication successful!");
385                return Ok(access_token);
386            }
387
388            if let Some(error) = &token_response.error
389                && error != "authorization_pending"
390            {
391                return Err(ProviderError::authentication(
392                    "github_copilot",
393                    format!("Access token error: OAuth error: {}", error),
394                ));
395            }
396        }
397
398        Err(ProviderError::authentication(
399            "github_copilot",
400            "Access token error: Timed out waiting for user authorization",
401        ))
402    }
403
404    /// Get standard GitHub headers
405    fn get_github_headers(&self, access_token: Option<&str>) -> reqwest::header::HeaderMap {
406        let mut headers = reqwest::header::HeaderMap::new();
407        headers.insert(
408            "accept",
409            "application/json"
410                .parse()
411                .expect("static header value 'application/json' is always valid"),
412        );
413        headers.insert(
414            "content-type",
415            "application/json"
416                .parse()
417                .expect("static header value 'application/json' is always valid"),
418        );
419        headers.insert(
420            "editor-version",
421            "vscode/1.85.1"
422                .parse()
423                .expect("static header value 'vscode/1.85.1' is always valid"),
424        );
425        headers.insert(
426            "editor-plugin-version",
427            "copilot/1.155.0"
428                .parse()
429                .expect("static header value 'copilot/1.155.0' is always valid"),
430        );
431        headers.insert(
432            "user-agent",
433            "GithubCopilot/1.155.0"
434                .parse()
435                .expect("static header value 'GithubCopilot/1.155.0' is always valid"),
436        );
437        headers.insert(
438            "accept-encoding",
439            "gzip,deflate,br"
440                .parse()
441                .expect("static header value 'gzip,deflate,br' is always valid"),
442        );
443
444        if let Some(token) = access_token {
445            if let Ok(value) = format!("token {}", token).parse() {
446                headers.insert("authorization", value);
447            } else {
448                tracing::warn!(
449                    "GitHub Copilot access token contains invalid header characters, skipping authorization header"
450                );
451            }
452        }
453
454        headers
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[tokio::test]
463    async fn captured_credentials_survive_file_rotation_and_clone() {
464        let directory = tempfile::tempdir().unwrap();
465        let access_path = directory.path().join("access-token");
466        let key_path = directory.path().join("api-key.json");
467        let write = |suffix: &str| {
468            fs::write(&access_path, format!("  fixture-access-{suffix}\n")).unwrap();
469            fs::write(
470                &key_path,
471                serde_json::json!({
472                    "token": format!("fixture-key-{suffix}"), "expires_at": u64::MAX,
473                    "endpoints": {"api": format!("https://{suffix}.fixture.invalid")}
474                })
475                .to_string(),
476            )
477            .unwrap();
478        };
479        write("a");
480        let config = GitHubCopilotConfig {
481            token_dir: Some(directory.path().to_str().unwrap().into()),
482            access_token_file: Some("access-token".into()),
483            api_key_file: Some("api-key.json".into()),
484            ..Default::default()
485        };
486        let original = CopilotAuthenticator::new(&config);
487        let replica = CopilotAuthenticator::new(&config);
488        let cloned = original.clone();
489        assert_eq!(
490            original.credential_resource_identity(),
491            replica.credential_resource_identity()
492        );
493        write("b");
494        for captured in [&original, &cloned] {
495            assert_eq!(
496                captured.get_access_token().await.unwrap(),
497                "fixture-access-a"
498            );
499            assert_eq!(captured.get_api_key().await.unwrap(), "fixture-key-a");
500            assert_eq!(
501                captured.get_api_base().as_deref(),
502                Some("https://a.fixture.invalid")
503            );
504        }
505        let replacement = CopilotAuthenticator::new(&config);
506        assert_ne!(
507            original.credential_resource_identity(),
508            replacement.credential_resource_identity()
509        );
510        assert_eq!(
511            replacement.get_access_token().await.unwrap(),
512            "fixture-access-b"
513        );
514        assert_eq!(replacement.get_api_key().await.unwrap(), "fixture-key-b");
515        // Normal refresh changes this resource's cache, not its construction identity.
516        let identity = original.credential_resource_identity();
517        original.api_key.write().as_mut().unwrap().token = "fixture-refreshed-key".into();
518        assert_eq!(cloned.get_api_key().await.unwrap(), "fixture-refreshed-key");
519        assert_eq!(original.credential_resource_identity(), identity);
520        assert!(!format!("{original:?}").contains("fixture-"));
521    }
522
523    #[tokio::test]
524    async fn captured_refresh_preserves_rotated_files_and_tracks_owned_writes() {
525        for rotation in ["none", "access", "api", "both"] {
526            let directory = tempfile::tempdir().unwrap();
527            let access_path = directory.path().join("access-token");
528            let key_path = directory.path().join("api-key.json");
529            let key = |suffix: &str| ApiKeyInfo {
530                token: format!("fixture-key-{suffix}"),
531                expires_at: u64::MAX,
532                endpoints: Endpoints {
533                    api: Some(format!("https://{suffix}.fixture.invalid")),
534                },
535            };
536            fs::write(&access_path, "fixture-access-a").unwrap();
537            fs::write(&key_path, serde_json::to_vec(&key("a")).unwrap()).unwrap();
538            let config = GitHubCopilotConfig {
539                token_dir: Some(directory.path().to_str().unwrap().into()),
540                access_token_file: Some("access-token".into()),
541                api_key_file: Some("api-key.json".into()),
542                ..Default::default()
543            };
544            let captured = CopilotAuthenticator::new(&config);
545            let cloned = captured.clone();
546            let identity = captured.credential_resource_identity();
547            if matches!(rotation, "access" | "both") {
548                fs::write(&access_path, "fixture-access-b").unwrap();
549            }
550            if matches!(rotation, "api" | "both") {
551                fs::write(&key_path, serde_json::to_vec(&key("b")).unwrap()).unwrap();
552            }
553            let access_before = fs::read(&access_path).unwrap();
554            let key_before = fs::read(&key_path).unwrap();
555            for suffix in ["refreshed-1", "refreshed-2"] {
556                let refreshed = key(suffix);
557                // Exercise the production refresh's persistence boundary after
558                // its directory check and construction-owned cache update.
559                captured.ensure_token_dir().unwrap();
560                *captured.api_key.write() = Some(refreshed.clone());
561                cloned
562                    .persist_credential(&key_path, &serde_json::to_vec(&refreshed).unwrap())
563                    .unwrap();
564                assert_eq!(cloned.get_access_token().await.unwrap(), "fixture-access-a");
565                assert_eq!(cloned.get_api_key().await.unwrap(), refreshed.token);
566                assert_eq!(cloned.get_api_base(), refreshed.endpoints.api);
567                assert_eq!(cloned.credential_resource_identity(), identity);
568                assert_eq!(fs::read(&access_path).unwrap(), access_before);
569                if rotation == "none" {
570                    assert_eq!(
571                        fs::read(&key_path).unwrap(),
572                        serde_json::to_vec(&refreshed).unwrap()
573                    );
574                } else {
575                    assert_eq!(fs::read(&key_path).unwrap(), key_before, "{rotation}");
576                }
577            }
578            let replacement = CopilotAuthenticator::new(&config);
579            assert_ne!(replacement.credential_resource_identity(), identity);
580            let expected_access = if matches!(rotation, "access" | "both") {
581                "fixture-access-b"
582            } else {
583                "fixture-access-a"
584            };
585            let expected_key = match rotation {
586                "none" => "fixture-key-refreshed-2",
587                "access" => "fixture-key-a",
588                _ => "fixture-key-b",
589            };
590            assert_eq!(
591                replacement.get_access_token().await.unwrap(),
592                expected_access
593            );
594            assert_eq!(replacement.get_api_key().await.unwrap(), expected_key);
595        }
596    }
597
598    #[test]
599    fn captured_absence_does_not_treat_read_errors_as_owned_files() {
600        let directory = tempfile::tempdir().unwrap();
601        let config = GitHubCopilotConfig {
602            token_dir: Some(directory.path().to_str().unwrap().into()),
603            access_token_file: Some("access-token".into()),
604            api_key_file: Some("api-key.json".into()),
605            ..Default::default()
606        };
607        let captured = CopilotAuthenticator::new(&config);
608        captured
609            .persist_credential(&captured.api_key_path, b"fixture-initial")
610            .unwrap();
611        fs::create_dir(&captured.access_token_path).unwrap();
612        assert!(
613            captured
614                .persist_credential(&captured.api_key_path, b"fixture-replacement")
615                .is_err()
616        );
617        assert_eq!(
618            fs::read(&captured.api_key_path).unwrap(),
619            b"fixture-initial"
620        );
621    }
622
623    #[test]
624    fn absent_or_invalid_cache_preserves_lazy_authentication() {
625        let directory = tempfile::tempdir().unwrap();
626        fs::write(directory.path().join("access-token"), " \n").unwrap();
627        fs::write(
628            directory.path().join("api-key.json"),
629            "invalid fixture JSON",
630        )
631        .unwrap();
632        let config = GitHubCopilotConfig {
633            token_dir: Some(directory.path().to_str().unwrap().into()),
634            access_token_file: Some("access-token".into()),
635            api_key_file: Some("api-key.json".into()),
636            ..Default::default()
637        };
638        let auth = CopilotAuthenticator::new(&config);
639        assert!(auth.access_token.read().is_none());
640        assert!(auth.api_key.read().is_none());
641        assert!(auth.get_api_base().is_none());
642    }
643
644    #[test]
645    fn test_authenticator_creation() {
646        let config = GitHubCopilotConfig::default();
647        let auth = CopilotAuthenticator::new(&config);
648
649        assert!(auth.token_dir.to_string_lossy().contains("github_copilot"));
650    }
651
652    #[test]
653    fn test_api_key_info_serialization() {
654        let info = ApiKeyInfo {
655            token: "test-token".to_string(),
656            expires_at: 1234567890,
657            endpoints: Endpoints {
658                api: Some("https://api.example.com".to_string()),
659            },
660        };
661
662        let json = serde_json::to_string(&info).unwrap();
663        assert!(json.contains("test-token"));
664        assert!(json.contains("1234567890"));
665
666        let deserialized: ApiKeyInfo = serde_json::from_str(&json).unwrap();
667        assert_eq!(deserialized.token, "test-token");
668        assert_eq!(deserialized.expires_at, 1234567890);
669    }
670
671    #[test]
672    fn test_api_key_info_deserialization_with_defaults() {
673        let json = r#"{"token": "test", "expires_at": 123}"#;
674        let info: ApiKeyInfo = serde_json::from_str(json).unwrap();
675        assert_eq!(info.token, "test");
676        assert!(info.endpoints.api.is_none());
677    }
678
679    #[test]
680    fn test_get_github_headers() {
681        let config = GitHubCopilotConfig::default();
682        let auth = CopilotAuthenticator::new(&config);
683
684        let headers = auth.get_github_headers(None);
685        assert!(headers.get("accept").is_some());
686        assert!(headers.get("user-agent").is_some());
687        assert!(headers.get("authorization").is_none());
688
689        let headers = auth.get_github_headers(Some("test-token"));
690        assert!(headers.get("authorization").is_some());
691    }
692}