1use 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
20const GITHUB_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
22
23const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
25
26const GITHUB_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
28
29const GITHUB_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token";
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ApiKeyInfo {
35 pub token: String,
37 pub expires_at: u64,
39 #[serde(default)]
41 pub endpoints: Endpoints,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct Endpoints {
47 pub api: Option<String>,
49}
50
51#[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#[derive(Debug, Deserialize)]
63struct AccessTokenResponse {
64 access_token: Option<String>,
65 error: Option<String>,
66}
67
68#[derive(Clone)]
70pub struct CopilotAuthenticator {
71 token_dir: PathBuf,
73 access_token_path: PathBuf,
75 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 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 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 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 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 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 warn!("No cached access token found, need to authenticate");
181
182 for attempt in 1..=3 {
184 debug!("Access token acquisition attempt {}/3", attempt);
185 match self.perform_device_flow().await {
186 Ok(token) => {
187 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 pub async fn get_api_key(&self) -> Result<String, GitHubCopilotError> {
217 if let Some(api_key_info) = self.api_key.read().clone() {
218 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 self.refresh_api_key().await
232 }
233
234 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 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 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 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 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 println!(
343 "\nPlease visit {} and enter code {} to authenticate.\n",
344 device_code_response.verification_uri, device_code_response.user_code
345 );
346
347 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; 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 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 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 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}