liter_llm 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,615 @@
1
+ //! GitHub Copilot OAuth Device Flow credential provider.
2
+ //!
3
+ //! Implements a two-token system:
4
+ //!
5
+ //! 1. **GitHub OAuth Access Token** (long-lived) — obtained once via the OAuth
6
+ //! Device Flow and cached to disk. Reused across process restarts.
7
+ //! 2. **Copilot API Key** (short-lived) — exchanged for the access token via
8
+ //! `https://api.github.com/copilot_internal/v2/token` and cached in memory
9
+ //! with automatic refresh when within 5 minutes of expiry.
10
+ //!
11
+ //! # Environment variables
12
+ //!
13
+ //! | Variable | Default | Description |
14
+ //! |----------|---------|-------------|
15
+ //! | `GITHUB_COPILOT_CLIENT_ID` | `Iv1.b507a08c87ecfe98` | GitHub OAuth App client ID |
16
+ //! | `GITHUB_COPILOT_DEVICE_CODE_URL` | `https://github.com/login/device/code` | Device code endpoint |
17
+ //! | `GITHUB_COPILOT_ACCESS_TOKEN_URL` | `https://github.com/login/oauth/access_token` | Token poll endpoint |
18
+ //! | `GITHUB_COPILOT_API_KEY_URL` | `https://api.github.com/copilot_internal/v2/token` | Copilot key endpoint |
19
+ //! | `GITHUB_COPILOT_TOKEN_DIR` | `~/.config/liter-llm/github_copilot/` | Directory for cached tokens |
20
+ //! | `GITHUB_COPILOT_ACCESS_TOKEN_FILE` | *(derived from `TOKEN_DIR`)* | Full path override for access token |
21
+ //! | `GITHUB_COPILOT_API_KEY_FILE` | *(derived from `TOKEN_DIR`)* | Full path override for API key JSON |
22
+
23
+ use std::path::PathBuf;
24
+ use std::sync::Arc;
25
+ use std::time::Duration;
26
+
27
+ use secrecy::{ExposeSecret, SecretString};
28
+ use tokio::sync::RwLock;
29
+
30
+ use super::{Credential, CredentialProvider, StaticTokenProvider};
31
+ use crate::client::BoxFuture;
32
+ use crate::error::LiterLlmError;
33
+
34
+ // ── Constants ────────────────────────────────────────────────────────────────
35
+
36
+ /// Public GitHub OAuth App client ID for GitHub Copilot.
37
+ const DEFAULT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
38
+
39
+ /// Default device-code endpoint.
40
+ const DEFAULT_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
41
+
42
+ /// Default access-token poll endpoint.
43
+ const DEFAULT_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
44
+
45
+ /// Default Copilot internal API key endpoint.
46
+ const DEFAULT_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token";
47
+
48
+ /// Default cache sub-directory (relative to `~/.config`).
49
+ const DEFAULT_TOKEN_SUBDIR: &str = "liter-llm/github_copilot";
50
+
51
+ /// File name for the persisted GitHub access token.
52
+ const ACCESS_TOKEN_FILE_NAME: &str = "access-token";
53
+
54
+ /// File name for the persisted Copilot API key JSON.
55
+ const API_KEY_FILE_NAME: &str = "api-key.json";
56
+
57
+ /// OAuth scope requested from GitHub.
58
+ const OAUTH_SCOPE: &str = "read:user";
59
+
60
+ /// Number of poll attempts during the Device Flow before timing out.
61
+ const DEVICE_FLOW_POLL_ATTEMPTS: u32 = 12;
62
+
63
+ /// Delay between Device Flow poll attempts.
64
+ const DEVICE_FLOW_POLL_INTERVAL: Duration = Duration::from_secs(5);
65
+
66
+ /// Minimum remaining lifetime before a Copilot API key is considered expired.
67
+ const EXPIRY_BUFFER_SECS: u64 = 300;
68
+
69
+ // ── Internal types ────────────────────────────────────────────────────────────
70
+
71
+ /// In-memory cached Copilot API key with its expiry timestamp.
72
+ struct CachedToken {
73
+ token: SecretString,
74
+ /// Unix timestamp (seconds) at which the token expires.
75
+ expires_at: u64,
76
+ }
77
+
78
+ impl CachedToken {
79
+ /// Returns `true` if the token is still valid after subtracting the safety
80
+ /// buffer, i.e. more than [`EXPIRY_BUFFER_SECS`] remain before expiry.
81
+ fn is_valid(&self) -> bool {
82
+ let now = std::time::SystemTime::now()
83
+ .duration_since(std::time::UNIX_EPOCH)
84
+ .map(|d| d.as_secs())
85
+ .unwrap_or(0);
86
+ now + EXPIRY_BUFFER_SECS < self.expires_at
87
+ }
88
+ }
89
+
90
+ // ── Serde helpers (private) ──────────────────────────────────────────────────
91
+
92
+ #[derive(serde::Deserialize)]
93
+ struct DeviceCodeResponse {
94
+ device_code: String,
95
+ user_code: String,
96
+ verification_uri: String,
97
+ }
98
+
99
+ #[derive(serde::Deserialize)]
100
+ struct AccessTokenResponse {
101
+ access_token: Option<String>,
102
+ error: Option<String>,
103
+ }
104
+
105
+ #[derive(serde::Deserialize)]
106
+ struct ApiKeyResponse {
107
+ token: String,
108
+ expires_at: u64,
109
+ endpoints: Option<ApiKeyEndpoints>,
110
+ }
111
+
112
+ #[derive(serde::Deserialize)]
113
+ struct ApiKeyEndpoints {
114
+ api: Option<String>,
115
+ }
116
+
117
+ /// Persisted representation of the Copilot API key on disk.
118
+ #[derive(serde::Serialize, serde::Deserialize)]
119
+ struct PersistedApiKey {
120
+ token: String,
121
+ expires_at: u64,
122
+ api_endpoint: Option<String>,
123
+ }
124
+
125
+ // ── Provider ─────────────────────────────────────────────────────────────────
126
+
127
+ /// GitHub Copilot credential provider using the OAuth Device Flow.
128
+ ///
129
+ /// Maintains two caches:
130
+ /// - A disk-persisted GitHub OAuth access token (long-lived, survives restarts).
131
+ /// - An in-memory Copilot API key (short-lived, refreshed automatically).
132
+ ///
133
+ /// On first use the user is prompted to visit a URL and enter a code.
134
+ /// Subsequent runs reuse the cached access token and API key transparently.
135
+ pub struct GithubCopilotCredentialProvider {
136
+ client_id: String,
137
+ device_code_url: String,
138
+ access_token_url: String,
139
+ api_key_url: String,
140
+ access_token_path: PathBuf,
141
+ api_key_path: PathBuf,
142
+ cached: RwLock<Option<CachedToken>>,
143
+ http_client: reqwest::Client,
144
+ }
145
+
146
+ impl GithubCopilotCredentialProvider {
147
+ /// Create a new provider with all defaults and the given HTTP client.
148
+ #[must_use]
149
+ pub fn new(http_client: reqwest::Client) -> Self {
150
+ let token_dir = default_token_dir();
151
+ Self {
152
+ client_id: DEFAULT_CLIENT_ID.to_owned(),
153
+ device_code_url: DEFAULT_DEVICE_CODE_URL.to_owned(),
154
+ access_token_url: DEFAULT_ACCESS_TOKEN_URL.to_owned(),
155
+ api_key_url: DEFAULT_API_KEY_URL.to_owned(),
156
+ access_token_path: token_dir.join(ACCESS_TOKEN_FILE_NAME),
157
+ api_key_path: token_dir.join(API_KEY_FILE_NAME),
158
+ cached: RwLock::new(None),
159
+ http_client,
160
+ }
161
+ }
162
+
163
+ /// Create a provider reading all configuration from environment variables.
164
+ ///
165
+ /// If no env-var overrides are set, all defaults are used. The provider
166
+ /// always performs the Device Flow interactively the first time.
167
+ ///
168
+ /// # Errors
169
+ ///
170
+ /// Returns [`LiterLlmError::Authentication`] if a path cannot be resolved.
171
+ pub fn from_env() -> Result<Arc<dyn CredentialProvider>, LiterLlmError> {
172
+ let http_client = reqwest::Client::new();
173
+
174
+ // Allow a pre-existing static token via the access-token env var.
175
+ // This matches the azure_ad fast-path pattern.
176
+ if let Ok(token) = std::env::var("GITHUB_COPILOT_TOKEN") {
177
+ return Ok(Arc::new(StaticTokenProvider::new(SecretString::from(token))));
178
+ }
179
+
180
+ let client_id = std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_else(|_| DEFAULT_CLIENT_ID.to_owned());
181
+
182
+ let device_code_url =
183
+ std::env::var("GITHUB_COPILOT_DEVICE_CODE_URL").unwrap_or_else(|_| DEFAULT_DEVICE_CODE_URL.to_owned());
184
+
185
+ let access_token_url =
186
+ std::env::var("GITHUB_COPILOT_ACCESS_TOKEN_URL").unwrap_or_else(|_| DEFAULT_ACCESS_TOKEN_URL.to_owned());
187
+
188
+ let api_key_url =
189
+ std::env::var("GITHUB_COPILOT_API_KEY_URL").unwrap_or_else(|_| DEFAULT_API_KEY_URL.to_owned());
190
+
191
+ let token_dir = std::env::var("GITHUB_COPILOT_TOKEN_DIR")
192
+ .map(PathBuf::from)
193
+ .unwrap_or_else(|_| default_token_dir());
194
+
195
+ let access_token_path = std::env::var("GITHUB_COPILOT_ACCESS_TOKEN_FILE")
196
+ .map(PathBuf::from)
197
+ .unwrap_or_else(|_| token_dir.join(ACCESS_TOKEN_FILE_NAME));
198
+
199
+ let api_key_path = std::env::var("GITHUB_COPILOT_API_KEY_FILE")
200
+ .map(PathBuf::from)
201
+ .unwrap_or_else(|_| token_dir.join(API_KEY_FILE_NAME));
202
+
203
+ Ok(Arc::new(Self {
204
+ client_id,
205
+ device_code_url,
206
+ access_token_url,
207
+ api_key_url,
208
+ access_token_path,
209
+ api_key_path,
210
+ cached: RwLock::new(None),
211
+ http_client,
212
+ }))
213
+ }
214
+
215
+ /// Return the Copilot API base URL from the cached api-key response, if
216
+ /// available. This endpoint is provider-specific and may differ from the
217
+ /// default `https://api.githubcopilot.com`.
218
+ ///
219
+ /// Returns `None` when no API key has been fetched yet or the response did
220
+ /// not include an `endpoints.api` field.
221
+ pub fn api_base(&self) -> Option<String> {
222
+ // Read from disk cache — cheap, avoids holding the async RwLock in a
223
+ // sync context.
224
+ let raw = std::fs::read_to_string(&self.api_key_path).ok()?;
225
+ let persisted: PersistedApiKey = serde_json::from_str(&raw).ok()?;
226
+ persisted.api_endpoint
227
+ }
228
+
229
+ // ── Private helpers ───────────────────────────────────────────────────────
230
+
231
+ /// Load the GitHub OAuth access token from disk, if present.
232
+ fn load_access_token(&self) -> Option<SecretString> {
233
+ let raw = std::fs::read_to_string(&self.access_token_path).ok()?;
234
+ let trimmed = raw.trim();
235
+ if trimmed.is_empty() {
236
+ None
237
+ } else {
238
+ Some(SecretString::from(trimmed.to_owned()))
239
+ }
240
+ }
241
+
242
+ /// Persist the GitHub OAuth access token to disk.
243
+ async fn save_access_token(&self, token: &SecretString) -> Result<(), LiterLlmError> {
244
+ if let Some(parent) = self.access_token_path.parent() {
245
+ tokio::fs::create_dir_all(parent)
246
+ .await
247
+ .map_err(|e| LiterLlmError::Authentication {
248
+ message: format!("failed to create token directory {}: {e}", parent.display()),
249
+ })?;
250
+ }
251
+ tokio::fs::write(&self.access_token_path, token.expose_secret())
252
+ .await
253
+ .map_err(|e| LiterLlmError::Authentication {
254
+ message: format!(
255
+ "failed to write access token to {}: {e}",
256
+ self.access_token_path.display()
257
+ ),
258
+ })
259
+ }
260
+
261
+ /// Run the GitHub OAuth Device Flow to obtain a new access token.
262
+ async fn run_device_flow(&self) -> Result<SecretString, LiterLlmError> {
263
+ // Step 1: request a device code.
264
+ let device_resp = self
265
+ .http_client
266
+ .post(&self.device_code_url)
267
+ .header("accept", "application/json")
268
+ .header("editor-version", "vscode/1.85.1")
269
+ .header("editor-plugin-version", "copilot/1.155.0")
270
+ .header("user-agent", "GithubCopilot/1.155.0")
271
+ .header("accept-encoding", "gzip,deflate,br")
272
+ .header("content-type", "application/json")
273
+ .json(&serde_json::json!({
274
+ "client_id": self.client_id,
275
+ "scope": OAUTH_SCOPE,
276
+ }))
277
+ .send()
278
+ .await
279
+ .map_err(|e| LiterLlmError::Authentication {
280
+ message: format!("GitHub device code request failed: {e}"),
281
+ })?;
282
+
283
+ let device_status = device_resp.status();
284
+ let device_body = device_resp.text().await.map_err(|e| LiterLlmError::Authentication {
285
+ message: format!("GitHub device code response unreadable: {e}"),
286
+ })?;
287
+
288
+ if !device_status.is_success() {
289
+ return Err(LiterLlmError::Authentication {
290
+ message: format!("GitHub device code request returned {device_status}: {device_body}"),
291
+ });
292
+ }
293
+
294
+ let device: DeviceCodeResponse =
295
+ serde_json::from_str(&device_body).map_err(|e| LiterLlmError::Authentication {
296
+ message: format!("GitHub device code response parse error: {e}"),
297
+ })?;
298
+
299
+ // Step 2: prompt the user.
300
+ eprintln!(
301
+ "\nTo authenticate with GitHub Copilot, visit: {}\nand enter code: {}\n",
302
+ device.verification_uri, device.user_code
303
+ );
304
+
305
+ // Step 3: poll for the access token.
306
+ for attempt in 0..DEVICE_FLOW_POLL_ATTEMPTS {
307
+ tokio::time::sleep(DEVICE_FLOW_POLL_INTERVAL).await;
308
+
309
+ let poll_resp = self
310
+ .http_client
311
+ .post(&self.access_token_url)
312
+ .header("accept", "application/json")
313
+ .header("editor-version", "vscode/1.85.1")
314
+ .header("editor-plugin-version", "copilot/1.155.0")
315
+ .header("user-agent", "GithubCopilot/1.155.0")
316
+ .header("accept-encoding", "gzip,deflate,br")
317
+ .header("content-type", "application/json")
318
+ .json(&serde_json::json!({
319
+ "client_id": self.client_id,
320
+ "device_code": device.device_code,
321
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
322
+ }))
323
+ .send()
324
+ .await
325
+ .map_err(|e| LiterLlmError::Authentication {
326
+ message: format!("GitHub access token poll request failed: {e}"),
327
+ })?;
328
+
329
+ let poll_body = poll_resp.text().await.map_err(|e| LiterLlmError::Authentication {
330
+ message: format!("GitHub access token poll response unreadable: {e}"),
331
+ })?;
332
+
333
+ let parsed: AccessTokenResponse =
334
+ serde_json::from_str(&poll_body).map_err(|e| LiterLlmError::Authentication {
335
+ message: format!("GitHub access token poll parse error: {e}"),
336
+ })?;
337
+
338
+ if let Some(token) = parsed.access_token
339
+ && !token.is_empty()
340
+ {
341
+ return Ok(SecretString::from(token));
342
+ }
343
+
344
+ if let Some(ref error) = parsed.error {
345
+ match error.as_str() {
346
+ "authorization_pending" | "slow_down" => {
347
+ // Continue polling.
348
+ }
349
+ other => {
350
+ return Err(LiterLlmError::Authentication {
351
+ message: format!("GitHub Device Flow error after attempt {attempt}: {other}"),
352
+ });
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ Err(LiterLlmError::Authentication {
359
+ message: format!(
360
+ "GitHub Device Flow timed out after {} attempts ({} seconds)",
361
+ DEVICE_FLOW_POLL_ATTEMPTS,
362
+ DEVICE_FLOW_POLL_ATTEMPTS * DEVICE_FLOW_POLL_INTERVAL.as_secs() as u32
363
+ ),
364
+ })
365
+ }
366
+
367
+ /// Obtain a valid GitHub OAuth access token, running the Device Flow if
368
+ /// necessary.
369
+ async fn get_or_create_access_token(&self) -> Result<SecretString, LiterLlmError> {
370
+ if let Some(token) = self.load_access_token() {
371
+ return Ok(token);
372
+ }
373
+ let token = self.run_device_flow().await?;
374
+ self.save_access_token(&token).await?;
375
+ Ok(token)
376
+ }
377
+
378
+ /// Exchange a GitHub OAuth access token for a short-lived Copilot API key.
379
+ async fn fetch_api_key(&self, access_token: &SecretString) -> Result<CachedToken, LiterLlmError> {
380
+ let resp = self
381
+ .http_client
382
+ .get(&self.api_key_url)
383
+ .header("accept", "application/json")
384
+ .header("editor-version", "vscode/1.85.1")
385
+ .header("editor-plugin-version", "copilot/1.155.0")
386
+ .header("user-agent", "GithubCopilot/1.155.0")
387
+ .header("accept-encoding", "gzip,deflate,br")
388
+ .header("authorization", format!("token {}", access_token.expose_secret()))
389
+ .send()
390
+ .await
391
+ .map_err(|e| LiterLlmError::Authentication {
392
+ message: format!("Copilot API key request failed: {e}"),
393
+ })?;
394
+
395
+ let status = resp.status();
396
+ let body = resp.text().await.map_err(|e| LiterLlmError::Authentication {
397
+ message: format!("Copilot API key response unreadable: {e}"),
398
+ })?;
399
+
400
+ if !status.is_success() {
401
+ return Err(LiterLlmError::Authentication {
402
+ message: format!("Copilot API key request returned {status}: {body}"),
403
+ });
404
+ }
405
+
406
+ let parsed: ApiKeyResponse = serde_json::from_str(&body).map_err(|e| LiterLlmError::Authentication {
407
+ message: format!("Copilot API key response parse error: {e}"),
408
+ })?;
409
+
410
+ // Persist to disk so `api_base()` can read it without holding a lock.
411
+ let api_endpoint = parsed.endpoints.as_ref().and_then(|e| e.api.clone());
412
+ let persisted = PersistedApiKey {
413
+ token: parsed.token.clone(),
414
+ expires_at: parsed.expires_at,
415
+ api_endpoint,
416
+ };
417
+ if let Some(parent) = self.api_key_path.parent() {
418
+ // Best-effort — if the directory exists we write; ignore create
419
+ // failures here since the token is still usable in memory.
420
+ let _ = tokio::fs::create_dir_all(parent).await;
421
+ }
422
+ let _ = tokio::fs::write(
423
+ &self.api_key_path,
424
+ serde_json::to_string(&persisted).unwrap_or_default(),
425
+ )
426
+ .await;
427
+
428
+ Ok(CachedToken {
429
+ token: SecretString::from(parsed.token),
430
+ expires_at: parsed.expires_at,
431
+ })
432
+ }
433
+ }
434
+
435
+ impl CredentialProvider for GithubCopilotCredentialProvider {
436
+ fn resolve(&self) -> BoxFuture<'_, Credential> {
437
+ Box::pin(async move {
438
+ // Fast path: read lock to check in-memory cache.
439
+ {
440
+ let guard = self.cached.read().await;
441
+ if let Some(ref cached) = *guard
442
+ && cached.is_valid()
443
+ {
444
+ return Ok(Credential::BearerToken(cached.token.clone()));
445
+ }
446
+ }
447
+
448
+ // Slow path: write lock to refresh.
449
+ let mut guard = self.cached.write().await;
450
+
451
+ // Double-check after acquiring write lock (another task may have refreshed).
452
+ if let Some(ref cached) = *guard
453
+ && cached.is_valid()
454
+ {
455
+ return Ok(Credential::BearerToken(cached.token.clone()));
456
+ }
457
+
458
+ let access_token = self.get_or_create_access_token().await?;
459
+ let fresh = self.fetch_api_key(&access_token).await?;
460
+ let token = fresh.token.clone();
461
+ *guard = Some(fresh);
462
+
463
+ Ok(Credential::BearerToken(token))
464
+ })
465
+ }
466
+ }
467
+
468
+ // ── Module-level helpers ──────────────────────────────────────────────────────
469
+
470
+ /// Resolve the default token directory: `~/.config/liter-llm/github_copilot/`.
471
+ ///
472
+ /// On Unix, uses `$XDG_CONFIG_HOME` if set, otherwise `$HOME/.config`.
473
+ /// On Windows, uses `%APPDATA%`.
474
+ /// Falls back to the current directory if no home directory can be determined.
475
+ fn default_token_dir() -> PathBuf {
476
+ platform_config_dir()
477
+ .unwrap_or_else(|| PathBuf::from("."))
478
+ .join(DEFAULT_TOKEN_SUBDIR)
479
+ }
480
+
481
+ /// Platform-portable config directory resolution (no external crate required).
482
+ fn platform_config_dir() -> Option<PathBuf> {
483
+ // Respect XDG on Unix.
484
+ if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
485
+ return Some(PathBuf::from(xdg));
486
+ }
487
+ // Windows: %APPDATA%
488
+ #[cfg(target_os = "windows")]
489
+ if let Ok(appdata) = std::env::var("APPDATA") {
490
+ return Some(PathBuf::from(appdata));
491
+ }
492
+ // Unix fallback: $HOME/.config
493
+ std::env::var("HOME")
494
+ .ok()
495
+ .map(|home| PathBuf::from(home).join(".config"))
496
+ }
497
+
498
+ // ── Tests ─────────────────────────────────────────────────────────────────────
499
+
500
+ #[cfg(test)]
501
+ mod tests {
502
+ use super::*;
503
+
504
+ fn make_cached_token(expires_at: u64) -> CachedToken {
505
+ CachedToken {
506
+ token: SecretString::from("test-token".to_owned()),
507
+ expires_at,
508
+ }
509
+ }
510
+
511
+ fn unix_now() -> u64 {
512
+ std::time::SystemTime::now()
513
+ .duration_since(std::time::UNIX_EPOCH)
514
+ .map(|d| d.as_secs())
515
+ .unwrap_or(0)
516
+ }
517
+
518
+ #[test]
519
+ fn cached_token_validity() {
520
+ // Expiry 1 hour in the future — well outside the 300s buffer.
521
+ let token = make_cached_token(unix_now() + 3600);
522
+ assert!(token.is_valid());
523
+ }
524
+
525
+ #[test]
526
+ fn cached_token_expired() {
527
+ // Already expired.
528
+ let token = make_cached_token(unix_now().saturating_sub(60));
529
+ assert!(!token.is_valid());
530
+ }
531
+
532
+ #[test]
533
+ fn cached_token_within_buffer() {
534
+ // Expires in 200s — inside the 300s buffer, so treated as invalid.
535
+ let token = make_cached_token(unix_now() + 200);
536
+ assert!(!token.is_valid());
537
+ }
538
+
539
+ #[test]
540
+ fn api_key_response_parsing() {
541
+ let json = r#"{
542
+ "token": "tid=abc123;exp=9999999999;sku=copilot_for_business_seat",
543
+ "expires_at": 9999999999,
544
+ "endpoints": { "api": "https://api.githubcopilot.com" }
545
+ }"#;
546
+
547
+ let parsed: ApiKeyResponse = serde_json::from_str(json).expect("parse failed");
548
+ assert_eq!(parsed.token, "tid=abc123;exp=9999999999;sku=copilot_for_business_seat");
549
+ assert_eq!(parsed.expires_at, 9_999_999_999);
550
+ let endpoints = parsed.endpoints.expect("endpoints missing");
551
+ assert_eq!(endpoints.api.as_deref(), Some("https://api.githubcopilot.com"));
552
+ }
553
+
554
+ #[test]
555
+ fn api_key_response_parsing_no_endpoints() {
556
+ let json = r#"{ "token": "tok", "expires_at": 1234567890 }"#;
557
+ let parsed: ApiKeyResponse = serde_json::from_str(json).expect("parse failed");
558
+ assert_eq!(parsed.token, "tok");
559
+ assert!(parsed.endpoints.is_none());
560
+ }
561
+
562
+ #[test]
563
+ fn default_token_dir() {
564
+ let provider = GithubCopilotCredentialProvider::new(reqwest::Client::new());
565
+ // The paths must end with the canonical file names.
566
+ assert_eq!(
567
+ provider.access_token_path.file_name().and_then(|n| n.to_str()),
568
+ Some(ACCESS_TOKEN_FILE_NAME)
569
+ );
570
+ assert_eq!(
571
+ provider.api_key_path.file_name().and_then(|n| n.to_str()),
572
+ Some(API_KEY_FILE_NAME)
573
+ );
574
+ // Both paths must share the same parent directory.
575
+ assert_eq!(provider.access_token_path.parent(), provider.api_key_path.parent());
576
+ }
577
+
578
+ #[test]
579
+ fn env_override_client_id() {
580
+ // SAFETY: This is a single-threaded test that is the sole writer for
581
+ // this env var. Rust 2024 requires an unsafe block for set_var /
582
+ // remove_var because they are unsound in multi-threaded programs; the
583
+ // risk is acceptable in a focused unit test with no other threads
584
+ // accessing this variable concurrently.
585
+ unsafe {
586
+ std::env::set_var("GITHUB_COPILOT_CLIENT_ID", "custom-client-id");
587
+ }
588
+ let provider =
589
+ GithubCopilotCredentialProvider::from_env().expect("from_env should not fail with default paths");
590
+ // Downcast is not possible through Arc<dyn CredentialProvider>, so we
591
+ // validate construction success as a proxy for env-var reading.
592
+ unsafe {
593
+ std::env::remove_var("GITHUB_COPILOT_CLIENT_ID");
594
+ }
595
+ drop(provider);
596
+ }
597
+
598
+ #[test]
599
+ fn default_client_id_used_when_no_env() {
600
+ // SAFETY: sole writer in this test; see safety comment above.
601
+ unsafe {
602
+ std::env::remove_var("GITHUB_COPILOT_CLIENT_ID");
603
+ }
604
+ let provider = GithubCopilotCredentialProvider::new(reqwest::Client::new());
605
+ assert_eq!(provider.client_id, DEFAULT_CLIENT_ID);
606
+ }
607
+
608
+ #[tokio::test]
609
+ #[ignore] // Requires interactive browser session and live GitHub access.
610
+ async fn live_device_flow() {
611
+ let provider = GithubCopilotCredentialProvider::from_env().expect("from_env should succeed");
612
+ let credential = provider.resolve().await.expect("resolve should return a credential");
613
+ assert!(matches!(credential, Credential::BearerToken(_)));
614
+ }
615
+ }
@@ -2,6 +2,8 @@
2
2
  pub mod azure_ad;
3
3
  #[cfg(feature = "bedrock-auth")]
4
4
  pub mod bedrock_sts;
5
+ #[cfg(feature = "copilot-auth")]
6
+ pub mod github_copilot;
5
7
  #[cfg(feature = "vertex-auth")]
6
8
  pub mod vertex_oauth;
7
9