1use crate::agent_cx::AgentCx;
6use crate::config::Config;
7use crate::error::{Error, Result};
8use crate::provider_metadata::{canonical_provider_id, provider_auth_env_keys, provider_metadata};
9use asupersync::channel::oneshot;
10use base64::Engine as _;
11use fs4::fs_std::FileExt;
12use serde::{Deserialize, Serialize};
13use sha2::Digest as _;
14use std::collections::HashMap;
15use std::fmt::Write as _;
16use std::fs::{self, File};
17use std::io::{Read, Seek, SeekFrom, Write};
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21const ANTHROPIC_OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
22const ANTHROPIC_OAUTH_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
23const ANTHROPIC_OAUTH_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
24const ANTHROPIC_OAUTH_REDIRECT_URI: &str = "https://console.anthropic.com/oauth/code/callback";
25const ANTHROPIC_OAUTH_SCOPES: &str = "org:create_api_key user:profile user:inference";
26
27const OPENAI_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
29const OPENAI_CODEX_OAUTH_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
30const OPENAI_CODEX_OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
31const OPENAI_CODEX_OAUTH_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
32const OPENAI_CODEX_OAUTH_SCOPES: &str = "openid profile email offline_access";
33
34const GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID: &str =
36 "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
37const GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET: &str = "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";
38const GOOGLE_GEMINI_CLI_OAUTH_REDIRECT_URI: &str = "http://localhost:8085/oauth2callback";
39const GOOGLE_GEMINI_CLI_OAUTH_SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile";
40const GOOGLE_GEMINI_CLI_OAUTH_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
41const GOOGLE_GEMINI_CLI_OAUTH_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
42const GOOGLE_GEMINI_CLI_CODE_ASSIST_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com";
43
44const GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID: &str =
46 "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
47const GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET: &str = "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";
48const GOOGLE_ANTIGRAVITY_OAUTH_REDIRECT_URI: &str = "http://localhost:51121/oauth-callback";
49const GOOGLE_ANTIGRAVITY_OAUTH_SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs";
50const GOOGLE_ANTIGRAVITY_OAUTH_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
51const GOOGLE_ANTIGRAVITY_OAUTH_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
52const GOOGLE_ANTIGRAVITY_DEFAULT_PROJECT_ID: &str = "rising-fact-p41fc";
53const GOOGLE_ANTIGRAVITY_PROJECT_DISCOVERY_ENDPOINTS: [&str; 2] = [
54 "https://cloudcode-pa.googleapis.com",
55 "https://daily-cloudcode-pa.sandbox.googleapis.com",
56];
57
58const ANTHROPIC_OAUTH_BEARER_MARKER: &str = "__pi_anthropic_oauth_bearer__:";
61
62const GITHUB_OAUTH_AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
64const GITHUB_OAUTH_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
65const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
66const GITHUB_COPILOT_SCOPES: &str = "read:user";
68
69const GITLAB_OAUTH_AUTHORIZE_PATH: &str = "/oauth/authorize";
71const GITLAB_OAUTH_TOKEN_PATH: &str = "/oauth/token";
72const GITLAB_DEFAULT_BASE_URL: &str = "https://gitlab.com";
73const GITLAB_DEFAULT_SCOPES: &str = "api read_api read_user";
75
76const KIMI_CODE_OAUTH_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
78const KIMI_CODE_OAUTH_DEFAULT_HOST: &str = "https://auth.kimi.com";
79const KIMI_CODE_OAUTH_HOST_ENV_KEYS: [&str; 2] = ["KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST"];
80const KIMI_SHARE_DIR_ENV_KEY: &str = "KIMI_SHARE_DIR";
81const KIMI_CODE_DEVICE_AUTHORIZATION_PATH: &str = "/api/oauth/device_authorization";
82const KIMI_CODE_TOKEN_PATH: &str = "/api/oauth/token";
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum AuthCredential {
88 ApiKey {
89 key: String,
90 },
91 OAuth {
92 access_token: String,
93 refresh_token: String,
94 expires: i64, #[serde(default, skip_serializing_if = "Option::is_none")]
97 token_url: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 client_id: Option<String>,
101 },
102 AwsCredentials {
107 access_key_id: String,
108 secret_access_key: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 session_token: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 region: Option<String>,
113 },
114 BearerToken {
119 token: String,
120 },
121 ServiceKey {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 client_id: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 client_secret: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 token_url: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 service_url: Option<String>,
132 },
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum CredentialStatus {
138 Missing,
139 ApiKey,
140 OAuthValid { expires_in_ms: i64 },
141 OAuthExpired { expired_by_ms: i64 },
142 BearerToken,
143 AwsCredentials,
144 ServiceKey,
145}
146
147const PROACTIVE_REFRESH_WINDOW_MS: i64 = 10 * 60 * 1000; type OAuthRefreshRequest = (String, String, String, Option<String>, Option<String>);
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct AuthFile {
154 #[serde(flatten)]
155 pub entries: HashMap<String, AuthCredential>,
156}
157
158#[derive(Debug, Clone)]
160pub struct AuthStorage {
161 path: PathBuf,
162 entries: HashMap<String, AuthCredential>,
163}
164
165impl AuthStorage {
166 fn allow_external_provider_lookup(&self) -> bool {
167 self.path == Config::auth_path()
171 }
172
173 fn entry_case_insensitive(&self, key: &str) -> Option<&AuthCredential> {
174 self.entries.iter().find_map(|(existing, credential)| {
175 existing.eq_ignore_ascii_case(key).then_some(credential)
176 })
177 }
178
179 fn credential_for_provider(&self, provider: &str) -> Option<&AuthCredential> {
180 if let Some(credential) = self
181 .entries
182 .get(provider)
183 .or_else(|| self.entry_case_insensitive(provider))
184 {
185 return Some(credential);
186 }
187
188 let metadata = provider_metadata(provider)?;
189 if let Some(credential) = self
190 .entries
191 .get(metadata.canonical_id)
192 .or_else(|| self.entry_case_insensitive(metadata.canonical_id))
193 {
194 return Some(credential);
195 }
196
197 metadata.aliases.iter().find_map(|alias| {
198 self.entries
199 .get(*alias)
200 .or_else(|| self.entry_case_insensitive(alias))
201 })
202 }
203
204 pub fn load(path: PathBuf) -> Result<Self> {
206 let entries = if path.exists() {
207 let file = File::open(&path).map_err(|e| Error::auth(format!("auth.json: {e}")))?;
208 let mut locked = lock_file(file, Duration::from_secs(30))?;
209 let mut content = String::new();
211 locked.as_file_mut().read_to_string(&mut content)?;
212 let parsed: AuthFile = match serde_json::from_str(&content) {
213 Ok(file) => file,
214 Err(e) => {
215 tracing::warn!(
216 event = "pi.auth.parse_error",
217 error = %e,
218 "auth.json is corrupted; starting with empty credentials"
219 );
220 AuthFile::default()
221 }
222 };
223 parsed.entries
224 } else {
225 HashMap::new()
226 };
227
228 Ok(Self { path, entries })
229 }
230
231 pub async fn load_async(path: PathBuf) -> Result<Self> {
233 let (tx, rx) = oneshot::channel();
234 std::thread::spawn(move || {
235 let res = Self::load(path);
236 let cx = AgentCx::for_request();
237 let _ = tx.send(cx.cx(), res);
238 });
239
240 let cx = AgentCx::for_request();
241 rx.recv(cx.cx())
242 .await
243 .map_err(|_| Error::auth("Load task cancelled".to_string()))?
244 }
245
246 pub fn save(&self) -> Result<()> {
248 if let Some(parent) = self.path.parent() {
249 fs::create_dir_all(parent)?;
250 }
251
252 let mut options = File::options();
253 options.read(true).write(true).create(true).truncate(false);
254
255 #[cfg(unix)]
256 {
257 use std::os::unix::fs::OpenOptionsExt;
258 options.mode(0o600);
259 }
260
261 let file = options.open(&self.path)?;
262 let mut locked = lock_file(file, Duration::from_secs(30))?;
263
264 let data = serde_json::to_string_pretty(&AuthFile {
265 entries: self.entries.clone(),
266 })?;
267
268 let f = locked.as_file_mut();
270 f.seek(SeekFrom::Start(0))?;
271 f.set_len(0)?; f.write_all(data.as_bytes())?;
273 f.flush()?;
274
275 Ok(())
276 }
277
278 pub async fn save_async(&self) -> Result<()> {
280 let (tx, rx) = oneshot::channel();
281 let this = self.clone();
282
283 std::thread::spawn(move || {
284 let res = this.save();
285 let cx = AgentCx::for_request();
286 let _ = tx.send(cx.cx(), res);
287 });
288
289 let cx = AgentCx::for_request();
290 rx.recv(cx.cx())
291 .await
292 .map_err(|_| Error::auth("Save task cancelled".to_string()))?
293 }
294
295 pub fn get(&self, provider: &str) -> Option<&AuthCredential> {
297 self.entries.get(provider)
298 }
299
300 pub fn set(&mut self, provider: impl Into<String>, credential: AuthCredential) {
302 self.entries.insert(provider.into(), credential);
303 }
304
305 pub fn remove(&mut self, provider: &str) -> bool {
307 self.entries.remove(provider).is_some()
308 }
309
310 pub fn api_key(&self, provider: &str) -> Option<String> {
318 self.credential_for_provider(provider)
319 .and_then(api_key_from_credential)
320 }
321
322 pub fn provider_names(&self) -> Vec<String> {
324 let mut providers: Vec<String> = self.entries.keys().cloned().collect();
325 providers.sort();
326 providers
327 }
328
329 pub fn credential_status(&self, provider: &str) -> CredentialStatus {
331 let now = chrono::Utc::now().timestamp_millis();
332 let cred = self.credential_for_provider(provider);
333
334 let Some(cred) = cred else {
335 return if self.allow_external_provider_lookup()
336 && resolve_external_provider_api_key(provider).is_some()
337 {
338 CredentialStatus::ApiKey
339 } else {
340 CredentialStatus::Missing
341 };
342 };
343
344 match cred {
345 AuthCredential::ApiKey { .. } => CredentialStatus::ApiKey,
346 AuthCredential::OAuth { expires, .. } if *expires > now => {
347 CredentialStatus::OAuthValid {
348 expires_in_ms: expires.saturating_sub(now),
349 }
350 }
351 AuthCredential::OAuth { expires, .. } => CredentialStatus::OAuthExpired {
352 expired_by_ms: now.saturating_sub(*expires),
353 },
354 AuthCredential::BearerToken { .. } => CredentialStatus::BearerToken,
355 AuthCredential::AwsCredentials { .. } => CredentialStatus::AwsCredentials,
356 AuthCredential::ServiceKey { .. } => CredentialStatus::ServiceKey,
357 }
358 }
359
360 pub fn remove_provider_aliases(&mut self, provider: &str) -> bool {
364 let trimmed = provider.trim();
365 if trimmed.is_empty() {
366 return false;
367 }
368
369 let mut targets: Vec<String> = vec![trimmed.to_ascii_lowercase()];
370 if let Some(metadata) = provider_metadata(trimmed) {
371 targets.push(metadata.canonical_id.to_ascii_lowercase());
372 targets.extend(
373 metadata
374 .aliases
375 .iter()
376 .map(|alias| alias.to_ascii_lowercase()),
377 );
378 }
379 targets.sort();
380 targets.dedup();
381
382 let mut removed = false;
383 self.entries.retain(|key, _| {
384 let should_remove = targets
385 .iter()
386 .any(|target| key.eq_ignore_ascii_case(target));
387 if should_remove {
388 removed = true;
389 }
390 !should_remove
391 });
392 removed
393 }
394
395 pub fn has_stored_credential(&self, provider: &str) -> bool {
398 self.credential_for_provider(provider).is_some()
399 }
400
401 pub fn external_setup_source(&self, provider: &str) -> Option<&'static str> {
404 if !self.allow_external_provider_lookup() {
405 return None;
406 }
407 external_setup_source(provider)
408 }
409
410 pub fn resolve_api_key(&self, provider: &str, override_key: Option<&str>) -> Option<String> {
412 self.resolve_api_key_with_env_lookup(provider, override_key, |var| std::env::var(var).ok())
413 }
414
415 fn resolve_api_key_with_env_lookup<F>(
416 &self,
417 provider: &str,
418 override_key: Option<&str>,
419 mut env_lookup: F,
420 ) -> Option<String>
421 where
422 F: FnMut(&str) -> Option<String>,
423 {
424 if let Some(key) = override_key {
425 return Some(key.to_string());
426 }
427
428 if let Some(credential) = self.credential_for_provider(provider)
431 && let Some(key) = match credential {
432 AuthCredential::OAuth { .. }
433 if canonical_provider_id(provider).unwrap_or(provider) == "anthropic" =>
434 {
435 api_key_from_credential(credential)
436 .map(|token| mark_anthropic_oauth_bearer_token(&token))
437 }
438 AuthCredential::OAuth { .. } | AuthCredential::BearerToken { .. } => {
439 api_key_from_credential(credential)
440 }
441 _ => None,
442 }
443 {
444 return Some(key);
445 }
446
447 if let Some(key) = env_keys_for_provider(provider).iter().find_map(|var| {
448 env_lookup(var).and_then(|value| {
449 let trimmed = value.trim();
450 if trimmed.is_empty() {
451 None
452 } else {
453 Some(trimmed.to_string())
454 }
455 })
456 }) {
457 return Some(key);
458 }
459
460 if let Some(key) = self.api_key(provider) {
461 return Some(key);
462 }
463
464 if self.allow_external_provider_lookup() {
465 if let Some(key) = resolve_external_provider_api_key(provider) {
466 return Some(key);
467 }
468 }
469
470 canonical_provider_id(provider)
471 .filter(|canonical| *canonical != provider)
472 .and_then(|canonical| {
473 self.api_key(canonical).or_else(|| {
474 self.allow_external_provider_lookup()
475 .then(|| resolve_external_provider_api_key(canonical))
476 .flatten()
477 })
478 })
479 }
480
481 pub async fn refresh_expired_oauth_tokens(&mut self) -> Result<()> {
486 let client = crate::http::client::Client::new();
487 self.refresh_expired_oauth_tokens_with_client(&client).await
488 }
489
490 #[allow(clippy::too_many_lines)]
495 pub async fn refresh_expired_oauth_tokens_with_client(
496 &mut self,
497 client: &crate::http::client::Client,
498 ) -> Result<()> {
499 let now = chrono::Utc::now().timestamp_millis();
500 let proactive_deadline = now + PROACTIVE_REFRESH_WINDOW_MS;
501 let mut refreshes: Vec<OAuthRefreshRequest> = Vec::new();
502
503 for (provider, cred) in &self.entries {
504 if let AuthCredential::OAuth {
505 access_token,
506 refresh_token,
507 expires,
508 token_url,
509 client_id,
510 ..
511 } = cred
512 {
513 if *expires <= proactive_deadline {
516 refreshes.push((
517 provider.clone(),
518 access_token.clone(),
519 refresh_token.clone(),
520 token_url.clone(),
521 client_id.clone(),
522 ));
523 }
524 }
525 }
526
527 let mut failed_providers = Vec::new();
528
529 for (provider, access_token, refresh_token, stored_token_url, stored_client_id) in refreshes
530 {
531 let result = match provider.as_str() {
532 "anthropic" => {
533 Box::pin(refresh_anthropic_oauth_token(client, &refresh_token)).await
534 }
535 "google-gemini-cli" => {
536 let (_, project_id) = decode_project_scoped_access_token(&access_token)
537 .ok_or_else(|| {
538 Error::auth(
539 "google-gemini-cli OAuth credential missing projectId payload"
540 .to_string(),
541 )
542 })?;
543 Box::pin(refresh_google_gemini_cli_oauth_token(
544 client,
545 &refresh_token,
546 &project_id,
547 ))
548 .await
549 }
550 "google-antigravity" => {
551 let (_, project_id) = decode_project_scoped_access_token(&access_token)
552 .ok_or_else(|| {
553 Error::auth(
554 "google-antigravity OAuth credential missing projectId payload"
555 .to_string(),
556 )
557 })?;
558 Box::pin(refresh_google_antigravity_oauth_token(
559 client,
560 &refresh_token,
561 &project_id,
562 ))
563 .await
564 }
565 "kimi-for-coding" => {
566 let token_url = stored_token_url
567 .clone()
568 .unwrap_or_else(kimi_code_token_endpoint);
569 Box::pin(refresh_kimi_code_oauth_token(
570 client,
571 &token_url,
572 &refresh_token,
573 ))
574 .await
575 }
576 _ => {
577 if let (Some(url), Some(cid)) = (&stored_token_url, &stored_client_id) {
578 Box::pin(refresh_self_contained_oauth_token(
579 client,
580 url,
581 cid,
582 &refresh_token,
583 &provider,
584 ))
585 .await
586 } else {
587 continue;
589 }
590 }
591 };
592
593 match result {
594 Ok(refreshed) => {
595 let name = provider.clone();
596 self.entries.insert(provider, refreshed);
597 if let Err(e) = self.save_async().await {
598 tracing::warn!("Failed to save auth.json after refreshing {name}: {e}");
599 }
600 }
601 Err(e) => {
602 tracing::warn!("Failed to refresh OAuth token for {provider}: {e}");
603 failed_providers.push(provider);
604 }
605 }
606 }
607
608 if !failed_providers.is_empty() {
609 return Err(Error::auth(format!(
612 "OAuth token refresh failed for: {}",
613 failed_providers.join(", ")
614 )));
615 }
616
617 Ok(())
618 }
619
620 pub async fn refresh_expired_extension_oauth_tokens(
626 &mut self,
627 client: &crate::http::client::Client,
628 extension_configs: &HashMap<String, crate::models::OAuthConfig>,
629 ) -> Result<()> {
630 let now = chrono::Utc::now().timestamp_millis();
631 let proactive_deadline = now + PROACTIVE_REFRESH_WINDOW_MS;
632 let mut refreshes = Vec::new();
633
634 for (provider, cred) in &self.entries {
635 if let AuthCredential::OAuth {
636 refresh_token,
637 expires,
638 token_url,
639 client_id,
640 ..
641 } = cred
642 {
643 if matches!(
645 provider.as_str(),
646 "anthropic"
647 | "openai-codex"
648 | "google-gemini-cli"
649 | "google-antigravity"
650 | "kimi-for-coding"
651 ) {
652 continue;
653 }
654 if token_url.is_some() && client_id.is_some() {
657 continue;
658 }
659 if *expires <= proactive_deadline {
660 if let Some(config) = extension_configs.get(provider) {
661 refreshes.push((provider.clone(), refresh_token.clone(), config.clone()));
662 }
663 }
664 }
665 }
666
667 if !refreshes.is_empty() {
668 tracing::info!(
669 event = "pi.auth.extension_oauth_refresh.start",
670 count = refreshes.len(),
671 "Refreshing expired extension OAuth tokens"
672 );
673 }
674 let mut failed_providers: Vec<String> = Vec::new();
675 for (provider, refresh_token, config) in refreshes {
676 let start = std::time::Instant::now();
677 match refresh_extension_oauth_token(client, &config, &refresh_token).await {
678 Ok(refreshed) => {
679 tracing::info!(
680 event = "pi.auth.extension_oauth_refresh.ok",
681 provider = %provider,
682 elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
683 "Extension OAuth token refreshed"
684 );
685 self.entries.insert(provider, refreshed);
686 self.save_async().await?;
687 }
688 Err(e) => {
689 tracing::warn!(
690 event = "pi.auth.extension_oauth_refresh.error",
691 provider = %provider,
692 error = %e,
693 elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
694 "Failed to refresh extension OAuth token; continuing with remaining providers"
695 );
696 failed_providers.push(provider);
697 }
698 }
699 }
700 if failed_providers.is_empty() {
701 Ok(())
702 } else {
703 Err(Error::api(format!(
704 "Extension OAuth token refresh failed for: {}",
705 failed_providers.join(", ")
706 )))
707 }
708 }
709
710 pub fn prune_stale_credentials(&mut self, max_age_ms: i64) -> Vec<String> {
716 let now = chrono::Utc::now().timestamp_millis();
717 let cutoff = now - max_age_ms;
718 let mut pruned = Vec::new();
719
720 self.entries.retain(|provider, cred| {
721 if let AuthCredential::OAuth {
722 expires,
723 token_url,
724 client_id,
725 ..
726 } = cred
727 {
728 if *expires < cutoff && token_url.is_none() && client_id.is_none() {
731 tracing::info!(
732 event = "pi.auth.prune_stale",
733 provider = %provider,
734 expired_at = expires,
735 "Pruning stale OAuth credential"
736 );
737 pruned.push(provider.clone());
738 return false;
739 }
740 }
741 true
742 });
743
744 pruned
745 }
746}
747
748fn api_key_from_credential(credential: &AuthCredential) -> Option<String> {
749 match credential {
750 AuthCredential::ApiKey { key } => Some(key.clone()),
751 AuthCredential::OAuth {
752 access_token,
753 expires,
754 ..
755 } => {
756 let now = chrono::Utc::now().timestamp_millis();
757 if *expires > now {
758 Some(access_token.clone())
759 } else {
760 None
761 }
762 }
763 AuthCredential::BearerToken { token } => Some(token.clone()),
764 AuthCredential::AwsCredentials { access_key_id, .. } => Some(access_key_id.clone()),
765 AuthCredential::ServiceKey { .. } => None,
766 }
767}
768
769fn env_key_for_provider(provider: &str) -> Option<&'static str> {
770 env_keys_for_provider(provider).first().copied()
771}
772
773fn mark_anthropic_oauth_bearer_token(token: &str) -> String {
774 format!("{ANTHROPIC_OAUTH_BEARER_MARKER}{token}")
775}
776
777pub(crate) fn unmark_anthropic_oauth_bearer_token(token: &str) -> Option<&str> {
778 token.strip_prefix(ANTHROPIC_OAUTH_BEARER_MARKER)
779}
780
781fn env_keys_for_provider(provider: &str) -> &'static [&'static str] {
782 provider_auth_env_keys(provider)
783}
784
785fn resolve_external_provider_api_key(provider: &str) -> Option<String> {
786 let canonical = canonical_provider_id(provider).unwrap_or(provider);
787 match canonical {
788 "anthropic" => read_external_claude_access_token()
789 .map(|token| mark_anthropic_oauth_bearer_token(&token)),
790 "openai" => read_external_codex_openai_api_key(),
793 "openai-codex" => read_external_codex_access_token(),
794 "google-gemini-cli" => {
795 let project =
796 google_project_id_from_env().or_else(google_project_id_from_gcloud_config);
797 read_external_gemini_access_payload(project.as_deref())
798 }
799 "google-antigravity" => {
800 let project = google_project_id_from_env()
801 .unwrap_or_else(|| GOOGLE_ANTIGRAVITY_DEFAULT_PROJECT_ID.to_string());
802 read_external_gemini_access_payload(Some(project.as_str()))
803 }
804 "kimi-for-coding" => read_external_kimi_code_access_token(),
805 _ => None,
806 }
807}
808
809pub fn external_setup_source(provider: &str) -> Option<&'static str> {
812 let canonical = canonical_provider_id(provider).unwrap_or(provider);
813 match canonical {
814 "anthropic" if read_external_claude_access_token().is_some() => {
815 Some("Claude Code (~/.claude/.credentials.json)")
816 }
817 "openai" if read_external_codex_openai_api_key().is_some() => {
818 Some("Codex (~/.codex/auth.json)")
819 }
820 "openai-codex" if read_external_codex_access_token().is_some() => {
821 Some("Codex (~/.codex/auth.json)")
822 }
823 "google-gemini-cli" => {
824 let project =
825 google_project_id_from_env().or_else(google_project_id_from_gcloud_config);
826 read_external_gemini_access_payload(project.as_deref())
827 .is_some()
828 .then_some("Gemini CLI (~/.gemini/oauth_creds.json)")
829 }
830 "google-antigravity" => {
831 let project = google_project_id_from_env()
832 .unwrap_or_else(|| GOOGLE_ANTIGRAVITY_DEFAULT_PROJECT_ID.to_string());
833 if read_external_gemini_access_payload(Some(project.as_str())).is_some() {
834 Some("Gemini CLI (~/.gemini/oauth_creds.json)")
835 } else {
836 None
837 }
838 }
839 "kimi-for-coding" if read_external_kimi_code_access_token().is_some() => Some(
840 "Kimi CLI (~/.kimi/credentials/kimi-code.json or $KIMI_SHARE_DIR/credentials/kimi-code.json)",
841 ),
842 _ => None,
843 }
844}
845
846fn read_external_json(path: &Path) -> Option<serde_json::Value> {
847 let content = std::fs::read_to_string(path).ok()?;
848 serde_json::from_str(&content).ok()
849}
850
851fn read_external_claude_access_token() -> Option<String> {
852 let path = home_dir()?.join(".claude").join(".credentials.json");
853 let value = read_external_json(&path)?;
854 let token = value
855 .get("claudeAiOauth")
856 .and_then(|oauth| oauth.get("accessToken"))
857 .and_then(serde_json::Value::as_str)?
858 .trim()
859 .to_string();
860 if token.is_empty() { None } else { Some(token) }
861}
862
863fn read_external_codex_auth() -> Option<serde_json::Value> {
864 let home = home_dir()?;
865 let candidates = [
866 home.join(".codex").join("auth.json"),
867 home.join(".config").join("codex").join("auth.json"),
868 ];
869 for path in candidates {
870 if let Some(value) = read_external_json(&path) {
871 return Some(value);
872 }
873 }
874 None
875}
876
877fn read_external_codex_access_token() -> Option<String> {
878 let value = read_external_codex_auth()?;
879 codex_access_token_from_value(&value)
880}
881
882fn read_external_codex_openai_api_key() -> Option<String> {
883 let value = read_external_codex_auth()?;
884 codex_openai_api_key_from_value(&value)
885}
886
887fn codex_access_token_from_value(value: &serde_json::Value) -> Option<String> {
888 let candidates = [
889 value
891 .get("tokens")
892 .and_then(|tokens| tokens.get("access_token"))
893 .and_then(serde_json::Value::as_str),
894 value
896 .get("tokens")
897 .and_then(|tokens| tokens.get("accessToken"))
898 .and_then(serde_json::Value::as_str),
899 value
901 .get("access_token")
902 .and_then(serde_json::Value::as_str),
903 value.get("accessToken").and_then(serde_json::Value::as_str),
904 value.get("token").and_then(serde_json::Value::as_str),
905 ];
906
907 candidates
908 .into_iter()
909 .flatten()
910 .map(str::trim)
911 .find(|token| !token.is_empty() && !token.starts_with("sk-"))
912 .map(std::string::ToString::to_string)
913}
914
915fn codex_openai_api_key_from_value(value: &serde_json::Value) -> Option<String> {
916 let candidates = [
917 value
918 .get("OPENAI_API_KEY")
919 .and_then(serde_json::Value::as_str),
920 value
921 .get("openai_api_key")
922 .and_then(serde_json::Value::as_str),
923 value
924 .get("openaiApiKey")
925 .and_then(serde_json::Value::as_str),
926 value
927 .get("env")
928 .and_then(|env| env.get("OPENAI_API_KEY"))
929 .and_then(serde_json::Value::as_str),
930 value
931 .get("env")
932 .and_then(|env| env.get("openai_api_key"))
933 .and_then(serde_json::Value::as_str),
934 value
935 .get("env")
936 .and_then(|env| env.get("openaiApiKey"))
937 .and_then(serde_json::Value::as_str),
938 ];
939
940 candidates
941 .into_iter()
942 .flatten()
943 .map(str::trim)
944 .find(|key| !key.is_empty())
945 .map(std::string::ToString::to_string)
946}
947
948fn read_external_gemini_access_payload(project_id: Option<&str>) -> Option<String> {
949 let home = home_dir()?;
950 let candidates = [
951 home.join(".gemini").join("oauth_creds.json"),
952 home.join(".config").join("gemini").join("credentials.json"),
953 ];
954
955 for path in candidates {
956 let Some(value) = read_external_json(&path) else {
957 continue;
958 };
959 let Some(token) = value
960 .get("access_token")
961 .and_then(serde_json::Value::as_str)
962 .map(str::trim)
963 .filter(|s| !s.is_empty())
964 else {
965 continue;
966 };
967
968 let project = project_id
969 .map(std::string::ToString::to_string)
970 .or_else(|| {
971 value
972 .get("projectId")
973 .or_else(|| value.get("project_id"))
974 .and_then(serde_json::Value::as_str)
975 .map(str::trim)
976 .filter(|s| !s.is_empty())
977 .map(std::string::ToString::to_string)
978 })
979 .or_else(google_project_id_from_gcloud_config)?;
980 let project = project.trim();
981 if project.is_empty() {
982 continue;
983 }
984
985 return Some(encode_project_scoped_access_token(token, project));
986 }
987
988 None
989}
990
991#[allow(clippy::cast_precision_loss)]
992fn read_external_kimi_code_access_token() -> Option<String> {
993 let share_dir = kimi_share_dir()?;
994 read_external_kimi_code_access_token_from_share_dir(&share_dir)
995}
996
997#[allow(clippy::cast_precision_loss)]
998fn read_external_kimi_code_access_token_from_share_dir(share_dir: &Path) -> Option<String> {
999 let path = share_dir.join("credentials").join("kimi-code.json");
1000 let value = read_external_json(&path)?;
1001
1002 let token = value
1003 .get("access_token")
1004 .and_then(serde_json::Value::as_str)
1005 .map(str::trim)
1006 .filter(|token| !token.is_empty())?;
1007
1008 let expires_at = value
1009 .get("expires_at")
1010 .and_then(|raw| raw.as_f64().or_else(|| raw.as_i64().map(|v| v as f64)));
1011 if let Some(expires_at) = expires_at {
1012 let now_seconds = chrono::Utc::now().timestamp() as f64;
1013 if expires_at <= now_seconds {
1014 return None;
1015 }
1016 }
1017
1018 Some(token.to_string())
1019}
1020
1021fn google_project_id_from_env() -> Option<String> {
1022 std::env::var("GOOGLE_CLOUD_PROJECT")
1023 .ok()
1024 .or_else(|| std::env::var("GOOGLE_CLOUD_PROJECT_ID").ok())
1025 .map(|value| value.trim().to_string())
1026 .filter(|value| !value.is_empty())
1027}
1028
1029fn gcloud_config_dir_with_env_lookup<F>(env_lookup: F) -> Option<PathBuf>
1030where
1031 F: Fn(&str) -> Option<String>,
1032{
1033 env_lookup("CLOUDSDK_CONFIG")
1034 .map(|value| value.trim().to_string())
1035 .filter(|value| !value.is_empty())
1036 .map(PathBuf::from)
1037 .or_else(|| {
1038 env_lookup("APPDATA")
1039 .map(|value| value.trim().to_string())
1040 .filter(|value| !value.is_empty())
1041 .map(|value| PathBuf::from(value).join("gcloud"))
1042 })
1043 .or_else(|| {
1044 env_lookup("XDG_CONFIG_HOME")
1045 .map(|value| value.trim().to_string())
1046 .filter(|value| !value.is_empty())
1047 .map(|value| PathBuf::from(value).join("gcloud"))
1048 })
1049 .or_else(|| {
1050 home_dir_with_env_lookup(env_lookup).map(|home| home.join(".config").join("gcloud"))
1051 })
1052}
1053
1054fn gcloud_active_config_name_with_env_lookup<F>(env_lookup: F) -> String
1055where
1056 F: Fn(&str) -> Option<String>,
1057{
1058 env_lookup("CLOUDSDK_ACTIVE_CONFIG_NAME")
1059 .map(|value| value.trim().to_string())
1060 .filter(|value| !value.is_empty())
1061 .unwrap_or_else(|| "default".to_string())
1062}
1063
1064fn google_project_id_from_gcloud_config_with_env_lookup<F>(env_lookup: F) -> Option<String>
1065where
1066 F: Fn(&str) -> Option<String>,
1067{
1068 let config_dir = gcloud_config_dir_with_env_lookup(&env_lookup)?;
1069 let config_name = gcloud_active_config_name_with_env_lookup(&env_lookup);
1070 let config_file = config_dir
1071 .join("configurations")
1072 .join(format!("config_{config_name}"));
1073 let Ok(content) = std::fs::read_to_string(config_file) else {
1074 return None;
1075 };
1076
1077 let mut section: Option<&str> = None;
1078 for raw_line in content.lines() {
1079 let line = raw_line.trim();
1080 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1081 continue;
1082 }
1083
1084 if let Some(rest) = line
1085 .strip_prefix('[')
1086 .and_then(|rest| rest.strip_suffix(']'))
1087 {
1088 section = Some(rest.trim());
1089 continue;
1090 }
1091
1092 if section != Some("core") {
1093 continue;
1094 }
1095
1096 let Some((key, value)) = line.split_once('=') else {
1097 continue;
1098 };
1099 if key.trim() != "project" {
1100 continue;
1101 }
1102 let project = value.trim();
1103 if project.is_empty() {
1104 continue;
1105 }
1106 return Some(project.to_string());
1107 }
1108
1109 None
1110}
1111
1112fn google_project_id_from_gcloud_config() -> Option<String> {
1113 google_project_id_from_gcloud_config_with_env_lookup(|key| std::env::var(key).ok())
1114}
1115
1116fn encode_project_scoped_access_token(token: &str, project_id: &str) -> String {
1117 serde_json::json!({
1118 "token": token,
1119 "projectId": project_id,
1120 })
1121 .to_string()
1122}
1123
1124fn decode_project_scoped_access_token(payload: &str) -> Option<(String, String)> {
1125 let value: serde_json::Value = serde_json::from_str(payload).ok()?;
1126 let token = value
1127 .get("token")
1128 .and_then(serde_json::Value::as_str)
1129 .map(str::trim)
1130 .filter(|s| !s.is_empty())?
1131 .to_string();
1132 let project_id = value
1133 .get("projectId")
1134 .or_else(|| value.get("project_id"))
1135 .and_then(serde_json::Value::as_str)
1136 .map(str::trim)
1137 .filter(|s| !s.is_empty())?
1138 .to_string();
1139 Some((token, project_id))
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq)]
1146pub enum AwsResolvedCredentials {
1147 Sigv4 {
1149 access_key_id: String,
1150 secret_access_key: String,
1151 session_token: Option<String>,
1152 region: String,
1153 },
1154 Bearer { token: String, region: String },
1156}
1157
1158pub fn resolve_aws_credentials(auth: &AuthStorage) -> Option<AwsResolvedCredentials> {
1169 resolve_aws_credentials_with_env(auth, |var| std::env::var(var).ok())
1170}
1171
1172fn resolve_aws_credentials_with_env<F>(
1173 auth: &AuthStorage,
1174 mut env: F,
1175) -> Option<AwsResolvedCredentials>
1176where
1177 F: FnMut(&str) -> Option<String>,
1178{
1179 let region = env("AWS_REGION")
1180 .or_else(|| env("AWS_DEFAULT_REGION"))
1181 .unwrap_or_else(|| "us-east-1".to_string());
1182
1183 if let Some(token) = env("AWS_BEARER_TOKEN_BEDROCK") {
1185 let token = token.trim().to_string();
1186 if !token.is_empty() {
1187 return Some(AwsResolvedCredentials::Bearer { token, region });
1188 }
1189 }
1190
1191 if let Some(access_key) = env("AWS_ACCESS_KEY_ID") {
1193 let access_key = access_key.trim().to_string();
1194 if !access_key.is_empty() {
1195 if let Some(secret_key) = env("AWS_SECRET_ACCESS_KEY") {
1196 let secret_key = secret_key.trim().to_string();
1197 if !secret_key.is_empty() {
1198 let session_token = env("AWS_SESSION_TOKEN")
1199 .map(|s| s.trim().to_string())
1200 .filter(|s| !s.is_empty());
1201 return Some(AwsResolvedCredentials::Sigv4 {
1202 access_key_id: access_key,
1203 secret_access_key: secret_key,
1204 session_token,
1205 region,
1206 });
1207 }
1208 }
1209 }
1210 }
1211
1212 let provider = "amazon-bedrock";
1214 match auth.get(provider) {
1215 Some(AuthCredential::AwsCredentials {
1216 access_key_id,
1217 secret_access_key,
1218 session_token,
1219 region: stored_region,
1220 }) => Some(AwsResolvedCredentials::Sigv4 {
1221 access_key_id: access_key_id.clone(),
1222 secret_access_key: secret_access_key.clone(),
1223 session_token: session_token.clone(),
1224 region: stored_region.clone().unwrap_or(region),
1225 }),
1226 Some(AuthCredential::BearerToken { token }) => Some(AwsResolvedCredentials::Bearer {
1227 token: token.clone(),
1228 region,
1229 }),
1230 Some(AuthCredential::ApiKey { key }) => {
1231 Some(AwsResolvedCredentials::Bearer {
1233 token: key.clone(),
1234 region,
1235 })
1236 }
1237 _ => None,
1238 }
1239}
1240
1241#[derive(Debug, Clone, PartialEq, Eq)]
1245pub struct SapResolvedCredentials {
1246 pub client_id: String,
1247 pub client_secret: String,
1248 pub token_url: String,
1249 pub service_url: String,
1250}
1251
1252pub fn resolve_sap_credentials(auth: &AuthStorage) -> Option<SapResolvedCredentials> {
1260 resolve_sap_credentials_with_env(auth, |var| std::env::var(var).ok())
1261}
1262
1263fn resolve_sap_credentials_with_env<F>(
1264 auth: &AuthStorage,
1265 mut env: F,
1266) -> Option<SapResolvedCredentials>
1267where
1268 F: FnMut(&str) -> Option<String>,
1269{
1270 if let Some(key_json) = env("AICORE_SERVICE_KEY") {
1272 if let Some(creds) = parse_sap_service_key_json(&key_json) {
1273 return Some(creds);
1274 }
1275 }
1276
1277 let client_id = env("SAP_AI_CORE_CLIENT_ID");
1279 let client_secret = env("SAP_AI_CORE_CLIENT_SECRET");
1280 let token_url = env("SAP_AI_CORE_TOKEN_URL");
1281 let service_url = env("SAP_AI_CORE_SERVICE_URL");
1282
1283 if let (Some(id), Some(secret), Some(turl), Some(surl)) =
1284 (client_id, client_secret, token_url, service_url)
1285 {
1286 let id = id.trim().to_string();
1287 let secret = secret.trim().to_string();
1288 let turl = turl.trim().to_string();
1289 let surl = surl.trim().to_string();
1290 if !id.is_empty() && !secret.is_empty() && !turl.is_empty() && !surl.is_empty() {
1291 return Some(SapResolvedCredentials {
1292 client_id: id,
1293 client_secret: secret,
1294 token_url: turl,
1295 service_url: surl,
1296 });
1297 }
1298 }
1299
1300 let provider = "sap-ai-core";
1302 if let Some(AuthCredential::ServiceKey {
1303 client_id,
1304 client_secret,
1305 token_url,
1306 service_url,
1307 }) = auth.get(provider)
1308 {
1309 if let (Some(id), Some(secret), Some(turl), Some(surl)) = (
1310 client_id.as_ref(),
1311 client_secret.as_ref(),
1312 token_url.as_ref(),
1313 service_url.as_ref(),
1314 ) {
1315 if !id.is_empty() && !secret.is_empty() && !turl.is_empty() && !surl.is_empty() {
1316 return Some(SapResolvedCredentials {
1317 client_id: id.clone(),
1318 client_secret: secret.clone(),
1319 token_url: turl.clone(),
1320 service_url: surl.clone(),
1321 });
1322 }
1323 }
1324 }
1325
1326 None
1327}
1328
1329fn parse_sap_service_key_json(json_str: &str) -> Option<SapResolvedCredentials> {
1331 let v: serde_json::Value = serde_json::from_str(json_str).ok()?;
1332 let obj = v.as_object()?;
1333
1334 let client_id = obj
1337 .get("clientid")
1338 .or_else(|| obj.get("client_id"))
1339 .and_then(|v| v.as_str())
1340 .filter(|s| !s.is_empty())?;
1341 let client_secret = obj
1342 .get("clientsecret")
1343 .or_else(|| obj.get("client_secret"))
1344 .and_then(|v| v.as_str())
1345 .filter(|s| !s.is_empty())?;
1346 let token_url = obj
1347 .get("url")
1348 .or_else(|| obj.get("token_url"))
1349 .and_then(|v| v.as_str())
1350 .filter(|s| !s.is_empty())?;
1351 let service_url = obj
1352 .get("serviceurls")
1353 .and_then(|v| v.get("AI_API_URL"))
1354 .and_then(|v| v.as_str())
1355 .or_else(|| obj.get("service_url").and_then(|v| v.as_str()))
1356 .filter(|s| !s.is_empty())?;
1357
1358 Some(SapResolvedCredentials {
1359 client_id: client_id.to_string(),
1360 client_secret: client_secret.to_string(),
1361 token_url: token_url.to_string(),
1362 service_url: service_url.to_string(),
1363 })
1364}
1365
1366#[derive(Debug, Deserialize)]
1367struct SapTokenExchangeResponse {
1368 access_token: String,
1369}
1370
1371pub async fn exchange_sap_access_token(auth: &AuthStorage) -> Result<Option<String>> {
1375 let Some(creds) = resolve_sap_credentials(auth) else {
1376 return Ok(None);
1377 };
1378
1379 let client = crate::http::client::Client::new();
1380 let token = exchange_sap_access_token_with_client(&client, &creds).await?;
1381 Ok(Some(token))
1382}
1383
1384async fn exchange_sap_access_token_with_client(
1385 client: &crate::http::client::Client,
1386 creds: &SapResolvedCredentials,
1387) -> Result<String> {
1388 let form_body = format!(
1389 "grant_type=client_credentials&client_id={}&client_secret={}",
1390 percent_encode_component(&creds.client_id),
1391 percent_encode_component(&creds.client_secret),
1392 );
1393
1394 let request = client
1395 .post(&creds.token_url)
1396 .header("Content-Type", "application/x-www-form-urlencoded")
1397 .header("Accept", "application/json")
1398 .body(form_body.into_bytes());
1399
1400 let response = Box::pin(request.send())
1401 .await
1402 .map_err(|e| Error::auth(format!("SAP AI Core token exchange failed: {e}")))?;
1403
1404 let status = response.status();
1405 let text = response
1406 .text()
1407 .await
1408 .unwrap_or_else(|_| "<failed to read body>".to_string());
1409 let redacted_text = redact_known_secrets(
1410 &text,
1411 &[creds.client_id.as_str(), creds.client_secret.as_str()],
1412 );
1413
1414 if !(200..300).contains(&status) {
1415 return Err(Error::auth(format!(
1416 "SAP AI Core token exchange failed (HTTP {status}): {redacted_text}"
1417 )));
1418 }
1419
1420 let response: SapTokenExchangeResponse = serde_json::from_str(&text)
1421 .map_err(|e| Error::auth(format!("SAP AI Core token response was invalid JSON: {e}")))?;
1422 let access_token = response.access_token.trim();
1423 if access_token.is_empty() {
1424 return Err(Error::auth(
1425 "SAP AI Core token exchange returned an empty access_token".to_string(),
1426 ));
1427 }
1428
1429 Ok(access_token.to_string())
1430}
1431
1432fn redact_known_secrets(text: &str, secrets: &[&str]) -> String {
1433 let mut redacted = text.to_string();
1434 for secret in secrets {
1435 let trimmed = secret.trim();
1436 if !trimmed.is_empty() {
1437 redacted = redacted.replace(trimmed, "[REDACTED]");
1438 }
1439 }
1440
1441 redact_sensitive_json_fields(&redacted)
1442}
1443
1444fn redact_sensitive_json_fields(text: &str) -> String {
1445 let Ok(mut json) = serde_json::from_str::<serde_json::Value>(text) else {
1446 return text.to_string();
1447 };
1448 redact_sensitive_json_value(&mut json);
1449 serde_json::to_string(&json).unwrap_or_else(|_| text.to_string())
1450}
1451
1452fn redact_sensitive_json_value(value: &mut serde_json::Value) {
1453 match value {
1454 serde_json::Value::Object(map) => {
1455 for (key, nested) in map {
1456 if is_sensitive_json_key(key) {
1457 *nested = serde_json::Value::String("[REDACTED]".to_string());
1458 } else {
1459 redact_sensitive_json_value(nested);
1460 }
1461 }
1462 }
1463 serde_json::Value::Array(items) => {
1464 for item in items {
1465 redact_sensitive_json_value(item);
1466 }
1467 }
1468 serde_json::Value::Null
1469 | serde_json::Value::Bool(_)
1470 | serde_json::Value::Number(_)
1471 | serde_json::Value::String(_) => {}
1472 }
1473}
1474
1475fn is_sensitive_json_key(key: &str) -> bool {
1476 let normalized: String = key
1477 .chars()
1478 .filter(char::is_ascii_alphanumeric)
1479 .map(|ch| ch.to_ascii_lowercase())
1480 .collect();
1481
1482 matches!(
1483 normalized.as_str(),
1484 "token"
1485 | "accesstoken"
1486 | "refreshtoken"
1487 | "idtoken"
1488 | "apikey"
1489 | "authorization"
1490 | "credential"
1491 | "secret"
1492 | "clientsecret"
1493 | "password"
1494 ) || normalized.ends_with("token")
1495 || normalized.ends_with("secret")
1496 || normalized.ends_with("apikey")
1497 || normalized.contains("authorization")
1498}
1499
1500#[derive(Debug, Clone)]
1501pub struct OAuthStartInfo {
1502 pub provider: String,
1503 pub url: String,
1504 pub verifier: String,
1505 pub instructions: Option<String>,
1506}
1507
1508#[derive(Debug, Clone, Serialize, Deserialize)]
1512pub struct DeviceCodeResponse {
1513 pub device_code: String,
1514 pub user_code: String,
1515 pub verification_uri: String,
1516 #[serde(default)]
1517 pub verification_uri_complete: Option<String>,
1518 pub expires_in: u64,
1519 #[serde(default = "default_device_interval")]
1520 pub interval: u64,
1521}
1522
1523const fn default_device_interval() -> u64 {
1524 5
1525}
1526
1527#[derive(Debug)]
1529pub enum DeviceFlowPollResult {
1530 Pending,
1532 SlowDown,
1534 Success(AuthCredential),
1536 Expired,
1538 AccessDenied,
1540 Error(String),
1542}
1543
1544#[derive(Debug, Clone)]
1551pub struct CopilotOAuthConfig {
1552 pub client_id: String,
1553 pub github_base_url: String,
1554 pub scopes: String,
1555}
1556
1557impl Default for CopilotOAuthConfig {
1558 fn default() -> Self {
1559 Self {
1560 client_id: String::new(),
1561 github_base_url: "https://github.com".to_string(),
1562 scopes: GITHUB_COPILOT_SCOPES.to_string(),
1563 }
1564 }
1565}
1566
1567#[derive(Debug, Clone)]
1572pub struct GitLabOAuthConfig {
1573 pub client_id: String,
1574 pub base_url: String,
1575 pub scopes: String,
1576 pub redirect_uri: Option<String>,
1577}
1578
1579impl Default for GitLabOAuthConfig {
1580 fn default() -> Self {
1581 Self {
1582 client_id: String::new(),
1583 base_url: GITLAB_DEFAULT_BASE_URL.to_string(),
1584 scopes: GITLAB_DEFAULT_SCOPES.to_string(),
1585 redirect_uri: None,
1586 }
1587 }
1588}
1589
1590fn percent_encode_component(value: &str) -> String {
1591 let mut out = String::with_capacity(value.len());
1592 for b in value.as_bytes() {
1593 match *b {
1594 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1595 out.push(*b as char);
1596 }
1597 b' ' => out.push_str("%20"),
1598 other => {
1599 let _ = write!(out, "%{other:02X}");
1600 }
1601 }
1602 }
1603 out
1604}
1605
1606fn percent_decode_component(value: &str) -> Option<String> {
1607 if !value.as_bytes().contains(&b'%') && !value.as_bytes().contains(&b'+') {
1608 return Some(value.to_string());
1609 }
1610
1611 let mut out = Vec::with_capacity(value.len());
1612 let mut bytes = value.as_bytes().iter().copied();
1613 while let Some(b) = bytes.next() {
1614 match b {
1615 b'+' => out.push(b' '),
1616 b'%' => {
1617 let hi = bytes.next()?;
1618 let lo = bytes.next()?;
1619 let hex = [hi, lo];
1620 let hex = std::str::from_utf8(&hex).ok()?;
1621 let decoded = u8::from_str_radix(hex, 16).ok()?;
1622 out.push(decoded);
1623 }
1624 other => out.push(other),
1625 }
1626 }
1627
1628 String::from_utf8(out).ok()
1629}
1630
1631fn parse_query_pairs(query: &str) -> Vec<(String, String)> {
1632 query
1633 .split('&')
1634 .filter(|part| !part.trim().is_empty())
1635 .filter_map(|part| {
1636 let (k, v) = part.split_once('=').unwrap_or((part, ""));
1637 let key = percent_decode_component(k.trim())?;
1638 let value = percent_decode_component(v.trim())?;
1639 Some((key, value))
1640 })
1641 .collect()
1642}
1643
1644fn build_url_with_query(base: &str, params: &[(&str, &str)]) -> String {
1645 let mut url = String::with_capacity(base.len() + 128);
1646 url.push_str(base);
1647 url.push('?');
1648
1649 for (idx, (k, v)) in params.iter().enumerate() {
1650 if idx > 0 {
1651 url.push('&');
1652 }
1653 url.push_str(&percent_encode_component(k));
1654 url.push('=');
1655 url.push_str(&percent_encode_component(v));
1656 }
1657
1658 url
1659}
1660
1661fn kimi_code_oauth_host_with_env_lookup<F>(env_lookup: F) -> String
1662where
1663 F: Fn(&str) -> Option<String>,
1664{
1665 KIMI_CODE_OAUTH_HOST_ENV_KEYS
1666 .iter()
1667 .find_map(|key| {
1668 env_lookup(key)
1669 .map(|value| value.trim().to_string())
1670 .filter(|value| !value.is_empty())
1671 })
1672 .unwrap_or_else(|| KIMI_CODE_OAUTH_DEFAULT_HOST.to_string())
1673}
1674
1675fn kimi_code_oauth_host() -> String {
1676 kimi_code_oauth_host_with_env_lookup(|key| std::env::var(key).ok())
1677}
1678
1679fn kimi_code_endpoint_for_host(host: &str, path: &str) -> String {
1680 format!("{}{}", trim_trailing_slash(host), path)
1681}
1682
1683fn kimi_code_token_endpoint() -> String {
1684 kimi_code_endpoint_for_host(&kimi_code_oauth_host(), KIMI_CODE_TOKEN_PATH)
1685}
1686
1687fn home_dir_with_env_lookup<F>(env_lookup: F) -> Option<PathBuf>
1688where
1689 F: Fn(&str) -> Option<String>,
1690{
1691 env_lookup("HOME")
1692 .map(|value| value.trim().to_string())
1693 .filter(|value| !value.is_empty())
1694 .map(PathBuf::from)
1695 .or_else(|| {
1696 env_lookup("USERPROFILE")
1697 .map(|value| value.trim().to_string())
1698 .filter(|value| !value.is_empty())
1699 .map(PathBuf::from)
1700 })
1701 .or_else(|| {
1702 let drive = env_lookup("HOMEDRIVE")
1703 .map(|value| value.trim().to_string())
1704 .filter(|value| !value.is_empty())?;
1705 let path = env_lookup("HOMEPATH")
1706 .map(|value| value.trim().to_string())
1707 .filter(|value| !value.is_empty())?;
1708 if path.starts_with('\\') || path.starts_with('/') {
1709 Some(PathBuf::from(format!("{drive}{path}")))
1710 } else {
1711 let mut combined = PathBuf::from(drive);
1712 combined.push(path);
1713 Some(combined)
1714 }
1715 })
1716}
1717
1718fn home_dir() -> Option<PathBuf> {
1719 home_dir_with_env_lookup(|key| std::env::var(key).ok())
1720}
1721
1722fn kimi_share_dir_with_env_lookup<F>(env_lookup: F) -> Option<PathBuf>
1723where
1724 F: Fn(&str) -> Option<String>,
1725{
1726 env_lookup(KIMI_SHARE_DIR_ENV_KEY)
1727 .map(|value| value.trim().to_string())
1728 .filter(|value| !value.is_empty())
1729 .map(PathBuf::from)
1730 .or_else(|| home_dir_with_env_lookup(env_lookup).map(|home| home.join(".kimi")))
1731}
1732
1733fn kimi_share_dir() -> Option<PathBuf> {
1734 kimi_share_dir_with_env_lookup(|key| std::env::var(key).ok())
1735}
1736
1737fn sanitize_ascii_header_value(value: &str, fallback: &str) -> String {
1738 if value.is_ascii() && !value.trim().is_empty() {
1739 return value.to_string();
1740 }
1741
1742 let sanitized = value
1743 .chars()
1744 .filter(char::is_ascii)
1745 .collect::<String>()
1746 .trim()
1747 .to_string();
1748 if sanitized.is_empty() {
1749 fallback.to_string()
1750 } else {
1751 sanitized
1752 }
1753}
1754
1755fn kimi_device_id_paths() -> Option<(PathBuf, PathBuf)> {
1756 let primary = kimi_share_dir()?.join("device_id");
1757 let legacy = home_dir().map_or_else(
1758 || primary.clone(),
1759 |home| home.join(".pi").join("agent").join("kimi-device-id"),
1760 );
1761 Some((primary, legacy))
1762}
1763
1764fn kimi_device_id() -> String {
1765 let generated = uuid::Uuid::new_v4().simple().to_string();
1766 let Some((primary, legacy)) = kimi_device_id_paths() else {
1767 return generated;
1768 };
1769
1770 for path in [&primary, &legacy] {
1771 if let Ok(existing) = fs::read_to_string(path) {
1772 let existing = existing.trim();
1773 if !existing.is_empty() {
1774 return existing.to_string();
1775 }
1776 }
1777 }
1778
1779 if let Some(parent) = primary.parent() {
1780 let _ = fs::create_dir_all(parent);
1781 }
1782
1783 if fs::write(&primary, generated.as_bytes()).is_ok() {
1784 #[cfg(unix)]
1785 {
1786 use std::os::unix::fs::PermissionsExt;
1787 let _ = fs::set_permissions(&primary, fs::Permissions::from_mode(0o600));
1788 }
1789 }
1790
1791 generated
1792}
1793
1794fn kimi_common_headers() -> Vec<(String, String)> {
1795 let device_name = std::env::var("HOSTNAME")
1796 .ok()
1797 .or_else(|| std::env::var("COMPUTERNAME").ok())
1798 .unwrap_or_else(|| "unknown".to_string());
1799 let device_model = format!("{} {}", std::env::consts::OS, std::env::consts::ARCH);
1800 let os_version = std::env::consts::OS.to_string();
1801
1802 vec![
1803 (
1804 "X-Msh-Platform".to_string(),
1805 sanitize_ascii_header_value("kimi_cli", "unknown"),
1806 ),
1807 (
1808 "X-Msh-Version".to_string(),
1809 sanitize_ascii_header_value(env!("CARGO_PKG_VERSION"), "unknown"),
1810 ),
1811 (
1812 "X-Msh-Device-Name".to_string(),
1813 sanitize_ascii_header_value(&device_name, "unknown"),
1814 ),
1815 (
1816 "X-Msh-Device-Model".to_string(),
1817 sanitize_ascii_header_value(&device_model, "unknown"),
1818 ),
1819 (
1820 "X-Msh-Os-Version".to_string(),
1821 sanitize_ascii_header_value(&os_version, "unknown"),
1822 ),
1823 (
1824 "X-Msh-Device-Id".to_string(),
1825 sanitize_ascii_header_value(&kimi_device_id(), "unknown"),
1826 ),
1827 ]
1828}
1829
1830pub fn start_anthropic_oauth() -> Result<OAuthStartInfo> {
1832 let (verifier, challenge) = generate_pkce();
1833
1834 let url = build_url_with_query(
1835 ANTHROPIC_OAUTH_AUTHORIZE_URL,
1836 &[
1837 ("code", "true"),
1838 ("client_id", ANTHROPIC_OAUTH_CLIENT_ID),
1839 ("response_type", "code"),
1840 ("redirect_uri", ANTHROPIC_OAUTH_REDIRECT_URI),
1841 ("scope", ANTHROPIC_OAUTH_SCOPES),
1842 ("code_challenge", &challenge),
1843 ("code_challenge_method", "S256"),
1844 ("state", &verifier),
1845 ],
1846 );
1847
1848 Ok(OAuthStartInfo {
1849 provider: "anthropic".to_string(),
1850 url,
1851 verifier,
1852 instructions: Some(
1853 "Open the URL, complete login, then paste the callback URL or authorization code."
1854 .to_string(),
1855 ),
1856 })
1857}
1858
1859pub async fn complete_anthropic_oauth(code_input: &str, verifier: &str) -> Result<AuthCredential> {
1861 let (code, state) = parse_oauth_code_input(code_input);
1862
1863 let Some(code) = code else {
1864 return Err(Error::auth("Missing authorization code".to_string()));
1865 };
1866
1867 let state = state.unwrap_or_else(|| verifier.to_string());
1868 if state != verifier {
1869 return Err(Error::auth("State mismatch".to_string()));
1870 }
1871
1872 let client = crate::http::client::Client::new();
1873 let request = client
1874 .post(ANTHROPIC_OAUTH_TOKEN_URL)
1875 .json(&serde_json::json!({
1876 "grant_type": "authorization_code",
1877 "client_id": ANTHROPIC_OAUTH_CLIENT_ID,
1878 "code": code,
1879 "state": state,
1880 "redirect_uri": ANTHROPIC_OAUTH_REDIRECT_URI,
1881 "code_verifier": verifier,
1882 }))?;
1883
1884 let response = Box::pin(request.send())
1885 .await
1886 .map_err(|e| Error::auth(format!("Token exchange failed: {e}")))?;
1887
1888 let status = response.status();
1889 let text = response
1890 .text()
1891 .await
1892 .unwrap_or_else(|_| "<failed to read body>".to_string());
1893 let redacted_text = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
1894
1895 if !(200..300).contains(&status) {
1896 return Err(Error::auth(format!(
1897 "Token exchange failed: {redacted_text}"
1898 )));
1899 }
1900
1901 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
1902 .map_err(|e| Error::auth(format!("Invalid token response: {e}")))?;
1903
1904 Ok(AuthCredential::OAuth {
1905 access_token: oauth_response.access_token,
1906 refresh_token: oauth_response.refresh_token,
1907 expires: oauth_expires_at_ms(oauth_response.expires_in),
1908 token_url: Some(ANTHROPIC_OAUTH_TOKEN_URL.to_string()),
1909 client_id: Some(ANTHROPIC_OAUTH_CLIENT_ID.to_string()),
1910 })
1911}
1912
1913async fn refresh_anthropic_oauth_token(
1914 client: &crate::http::client::Client,
1915 refresh_token: &str,
1916) -> Result<AuthCredential> {
1917 let request = client
1918 .post(ANTHROPIC_OAUTH_TOKEN_URL)
1919 .json(&serde_json::json!({
1920 "grant_type": "refresh_token",
1921 "client_id": ANTHROPIC_OAUTH_CLIENT_ID,
1922 "refresh_token": refresh_token,
1923 }))?;
1924
1925 let response = Box::pin(request.send())
1926 .await
1927 .map_err(|e| Error::auth(format!("Anthropic token refresh failed: {e}")))?;
1928
1929 let status = response.status();
1930 let text = response
1931 .text()
1932 .await
1933 .unwrap_or_else(|_| "<failed to read body>".to_string());
1934 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
1935
1936 if !(200..300).contains(&status) {
1937 return Err(Error::auth(format!(
1938 "Anthropic token refresh failed: {redacted_text}"
1939 )));
1940 }
1941
1942 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
1943 .map_err(|e| Error::auth(format!("Invalid refresh response: {e}")))?;
1944
1945 Ok(AuthCredential::OAuth {
1946 access_token: oauth_response.access_token,
1947 refresh_token: oauth_response.refresh_token,
1948 expires: oauth_expires_at_ms(oauth_response.expires_in),
1949 token_url: Some(ANTHROPIC_OAUTH_TOKEN_URL.to_string()),
1950 client_id: Some(ANTHROPIC_OAUTH_CLIENT_ID.to_string()),
1951 })
1952}
1953
1954pub fn start_openai_codex_oauth() -> Result<OAuthStartInfo> {
1956 let (verifier, challenge) = generate_pkce();
1957 let url = build_url_with_query(
1958 OPENAI_CODEX_OAUTH_AUTHORIZE_URL,
1959 &[
1960 ("response_type", "code"),
1961 ("client_id", OPENAI_CODEX_OAUTH_CLIENT_ID),
1962 ("redirect_uri", OPENAI_CODEX_OAUTH_REDIRECT_URI),
1963 ("scope", OPENAI_CODEX_OAUTH_SCOPES),
1964 ("code_challenge", &challenge),
1965 ("code_challenge_method", "S256"),
1966 ("state", &verifier),
1967 ("id_token_add_organizations", "true"),
1968 ("codex_cli_simplified_flow", "true"),
1969 ("originator", "pi"),
1970 ],
1971 );
1972
1973 Ok(OAuthStartInfo {
1974 provider: "openai-codex".to_string(),
1975 url,
1976 verifier,
1977 instructions: Some(
1978 "Open the URL, complete login, then paste the callback URL or authorization code."
1979 .to_string(),
1980 ),
1981 })
1982}
1983
1984pub async fn complete_openai_codex_oauth(
1986 code_input: &str,
1987 verifier: &str,
1988) -> Result<AuthCredential> {
1989 let (code, state) = parse_oauth_code_input(code_input);
1990 let Some(code) = code else {
1991 return Err(Error::auth("Missing authorization code".to_string()));
1992 };
1993 let state = state.unwrap_or_else(|| verifier.to_string());
1994 if state != verifier {
1995 return Err(Error::auth("State mismatch".to_string()));
1996 }
1997
1998 let form_body = format!(
1999 "grant_type=authorization_code&client_id={}&code={}&code_verifier={}&redirect_uri={}",
2000 percent_encode_component(OPENAI_CODEX_OAUTH_CLIENT_ID),
2001 percent_encode_component(&code),
2002 percent_encode_component(verifier),
2003 percent_encode_component(OPENAI_CODEX_OAUTH_REDIRECT_URI),
2004 );
2005
2006 let client = crate::http::client::Client::new();
2007 let request = client
2008 .post(OPENAI_CODEX_OAUTH_TOKEN_URL)
2009 .header("Content-Type", "application/x-www-form-urlencoded")
2010 .header("Accept", "application/json")
2011 .body(form_body.into_bytes());
2012
2013 let response = Box::pin(request.send())
2014 .await
2015 .map_err(|e| Error::auth(format!("OpenAI Codex token exchange failed: {e}")))?;
2016
2017 let status = response.status();
2018 let text = response
2019 .text()
2020 .await
2021 .unwrap_or_else(|_| "<failed to read body>".to_string());
2022 let redacted_text = redact_known_secrets(&text, &[code.as_str(), verifier]);
2023 if !(200..300).contains(&status) {
2024 return Err(Error::auth(format!(
2025 "OpenAI Codex token exchange failed: {redacted_text}"
2026 )));
2027 }
2028
2029 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2030 .map_err(|e| Error::auth(format!("Invalid OpenAI Codex token response: {e}")))?;
2031
2032 Ok(AuthCredential::OAuth {
2033 access_token: oauth_response.access_token,
2034 refresh_token: oauth_response.refresh_token,
2035 expires: oauth_expires_at_ms(oauth_response.expires_in),
2036 token_url: Some(OPENAI_CODEX_OAUTH_TOKEN_URL.to_string()),
2037 client_id: Some(OPENAI_CODEX_OAUTH_CLIENT_ID.to_string()),
2038 })
2039}
2040
2041pub fn start_google_gemini_cli_oauth() -> Result<OAuthStartInfo> {
2043 let (verifier, challenge) = generate_pkce();
2044 let url = build_url_with_query(
2045 GOOGLE_GEMINI_CLI_OAUTH_AUTHORIZE_URL,
2046 &[
2047 ("client_id", GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID),
2048 ("response_type", "code"),
2049 ("redirect_uri", GOOGLE_GEMINI_CLI_OAUTH_REDIRECT_URI),
2050 ("scope", GOOGLE_GEMINI_CLI_OAUTH_SCOPES),
2051 ("code_challenge", &challenge),
2052 ("code_challenge_method", "S256"),
2053 ("state", &verifier),
2054 ("access_type", "offline"),
2055 ("prompt", "consent"),
2056 ],
2057 );
2058
2059 Ok(OAuthStartInfo {
2060 provider: "google-gemini-cli".to_string(),
2061 url,
2062 verifier,
2063 instructions: Some(
2064 "Open the URL, complete login, then paste the callback URL or authorization code."
2065 .to_string(),
2066 ),
2067 })
2068}
2069
2070pub fn start_google_antigravity_oauth() -> Result<OAuthStartInfo> {
2072 let (verifier, challenge) = generate_pkce();
2073 let url = build_url_with_query(
2074 GOOGLE_ANTIGRAVITY_OAUTH_AUTHORIZE_URL,
2075 &[
2076 ("client_id", GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID),
2077 ("response_type", "code"),
2078 ("redirect_uri", GOOGLE_ANTIGRAVITY_OAUTH_REDIRECT_URI),
2079 ("scope", GOOGLE_ANTIGRAVITY_OAUTH_SCOPES),
2080 ("code_challenge", &challenge),
2081 ("code_challenge_method", "S256"),
2082 ("state", &verifier),
2083 ("access_type", "offline"),
2084 ("prompt", "consent"),
2085 ],
2086 );
2087
2088 Ok(OAuthStartInfo {
2089 provider: "google-antigravity".to_string(),
2090 url,
2091 verifier,
2092 instructions: Some(
2093 "Open the URL, complete login, then paste the callback URL or authorization code."
2094 .to_string(),
2095 ),
2096 })
2097}
2098
2099async fn discover_google_gemini_cli_project_id(
2100 client: &crate::http::client::Client,
2101 access_token: &str,
2102) -> Result<String> {
2103 let env_project = google_project_id_from_env();
2104 let mut payload = serde_json::json!({
2105 "metadata": {
2106 "ideType": "IDE_UNSPECIFIED",
2107 "platform": "PLATFORM_UNSPECIFIED",
2108 "pluginType": "GEMINI",
2109 }
2110 });
2111 if let Some(project) = &env_project {
2112 payload["cloudaicompanionProject"] = serde_json::Value::String(project.clone());
2113 payload["metadata"]["duetProject"] = serde_json::Value::String(project.clone());
2114 }
2115
2116 let request = client
2117 .post(&format!(
2118 "{GOOGLE_GEMINI_CLI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist"
2119 ))
2120 .header("Authorization", format!("Bearer {access_token}"))
2121 .header("Content-Type", "application/json")
2122 .json(&payload)?;
2123
2124 let response = Box::pin(request.send())
2125 .await
2126 .map_err(|e| Error::auth(format!("Google Cloud project discovery failed: {e}")))?;
2127 let status = response.status();
2128 let text = response
2129 .text()
2130 .await
2131 .unwrap_or_else(|_| "<failed to read body>".to_string());
2132
2133 if (200..300).contains(&status) {
2134 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2135 if let Some(project_id) = parse_code_assist_project_id(&value) {
2136 return Ok(project_id);
2137 }
2138 }
2139 }
2140
2141 if let Some(project_id) = env_project {
2142 return Ok(project_id);
2143 }
2144
2145 Err(Error::auth(
2146 "Google Cloud project discovery failed. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID and retry /login google-gemini-cli.".to_string(),
2147 ))
2148}
2149
2150async fn discover_google_antigravity_project_id(
2151 client: &crate::http::client::Client,
2152 access_token: &str,
2153) -> Result<String> {
2154 let payload = serde_json::json!({
2155 "metadata": {
2156 "ideType": "IDE_UNSPECIFIED",
2157 "platform": "PLATFORM_UNSPECIFIED",
2158 "pluginType": "GEMINI",
2159 }
2160 });
2161
2162 for endpoint in GOOGLE_ANTIGRAVITY_PROJECT_DISCOVERY_ENDPOINTS {
2163 let request = client
2164 .post(&format!("{endpoint}/v1internal:loadCodeAssist"))
2165 .header("Authorization", format!("Bearer {access_token}"))
2166 .header("Content-Type", "application/json")
2167 .json(&payload)?;
2168
2169 let Ok(response) = Box::pin(request.send()).await else {
2170 continue;
2171 };
2172 let status = response.status();
2173 if !(200..300).contains(&status) {
2174 continue;
2175 }
2176 let text = response.text().await.unwrap_or_default();
2177 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2178 if let Some(project_id) = parse_code_assist_project_id(&value) {
2179 return Ok(project_id);
2180 }
2181 }
2182 }
2183
2184 Ok(GOOGLE_ANTIGRAVITY_DEFAULT_PROJECT_ID.to_string())
2185}
2186
2187fn parse_code_assist_project_id(value: &serde_json::Value) -> Option<String> {
2188 value
2189 .get("cloudaicompanionProject")
2190 .and_then(|project| {
2191 project
2192 .as_str()
2193 .map(std::string::ToString::to_string)
2194 .or_else(|| {
2195 project
2196 .get("id")
2197 .and_then(serde_json::Value::as_str)
2198 .map(std::string::ToString::to_string)
2199 })
2200 })
2201 .map(|project| project.trim().to_string())
2202 .filter(|project| !project.is_empty())
2203}
2204
2205async fn exchange_google_authorization_code(
2206 client: &crate::http::client::Client,
2207 token_url: &str,
2208 client_id: &str,
2209 client_secret: &str,
2210 code: &str,
2211 redirect_uri: &str,
2212 verifier: &str,
2213) -> Result<OAuthTokenResponse> {
2214 let form_body = format!(
2215 "client_id={}&client_secret={}&code={}&grant_type=authorization_code&redirect_uri={}&code_verifier={}",
2216 percent_encode_component(client_id),
2217 percent_encode_component(client_secret),
2218 percent_encode_component(code),
2219 percent_encode_component(redirect_uri),
2220 percent_encode_component(verifier),
2221 );
2222
2223 let request = client
2224 .post(token_url)
2225 .header("Content-Type", "application/x-www-form-urlencoded")
2226 .header("Accept", "application/json")
2227 .body(form_body.into_bytes());
2228
2229 let response = Box::pin(request.send())
2230 .await
2231 .map_err(|e| Error::auth(format!("OAuth token exchange failed: {e}")))?;
2232 let status = response.status();
2233 let text = response
2234 .text()
2235 .await
2236 .unwrap_or_else(|_| "<failed to read body>".to_string());
2237 let redacted_text = redact_known_secrets(&text, &[code, verifier, client_secret]);
2238 if !(200..300).contains(&status) {
2239 return Err(Error::auth(format!(
2240 "OAuth token exchange failed: {redacted_text}"
2241 )));
2242 }
2243
2244 serde_json::from_str::<OAuthTokenResponse>(&text)
2245 .map_err(|e| Error::auth(format!("Invalid OAuth token response: {e}")))
2246}
2247
2248pub async fn complete_google_gemini_cli_oauth(
2250 code_input: &str,
2251 verifier: &str,
2252) -> Result<AuthCredential> {
2253 let (code, state) = parse_oauth_code_input(code_input);
2254 let Some(code) = code else {
2255 return Err(Error::auth("Missing authorization code".to_string()));
2256 };
2257 let state = state.unwrap_or_else(|| verifier.to_string());
2258 if state != verifier {
2259 return Err(Error::auth("State mismatch".to_string()));
2260 }
2261
2262 let client = crate::http::client::Client::new();
2263 let oauth_response = exchange_google_authorization_code(
2264 &client,
2265 GOOGLE_GEMINI_CLI_OAUTH_TOKEN_URL,
2266 GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID,
2267 GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET,
2268 &code,
2269 GOOGLE_GEMINI_CLI_OAUTH_REDIRECT_URI,
2270 verifier,
2271 )
2272 .await?;
2273
2274 let project_id =
2275 discover_google_gemini_cli_project_id(&client, &oauth_response.access_token).await?;
2276
2277 Ok(AuthCredential::OAuth {
2278 access_token: encode_project_scoped_access_token(&oauth_response.access_token, &project_id),
2279 refresh_token: oauth_response.refresh_token,
2280 expires: oauth_expires_at_ms(oauth_response.expires_in),
2281 token_url: None,
2282 client_id: None,
2283 })
2284}
2285
2286pub async fn complete_google_antigravity_oauth(
2288 code_input: &str,
2289 verifier: &str,
2290) -> Result<AuthCredential> {
2291 let (code, state) = parse_oauth_code_input(code_input);
2292 let Some(code) = code else {
2293 return Err(Error::auth("Missing authorization code".to_string()));
2294 };
2295 let state = state.unwrap_or_else(|| verifier.to_string());
2296 if state != verifier {
2297 return Err(Error::auth("State mismatch".to_string()));
2298 }
2299
2300 let client = crate::http::client::Client::new();
2301 let oauth_response = exchange_google_authorization_code(
2302 &client,
2303 GOOGLE_ANTIGRAVITY_OAUTH_TOKEN_URL,
2304 GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID,
2305 GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET,
2306 &code,
2307 GOOGLE_ANTIGRAVITY_OAUTH_REDIRECT_URI,
2308 verifier,
2309 )
2310 .await?;
2311
2312 let project_id =
2313 discover_google_antigravity_project_id(&client, &oauth_response.access_token).await?;
2314
2315 Ok(AuthCredential::OAuth {
2316 access_token: encode_project_scoped_access_token(&oauth_response.access_token, &project_id),
2317 refresh_token: oauth_response.refresh_token,
2318 expires: oauth_expires_at_ms(oauth_response.expires_in),
2319 token_url: None,
2320 client_id: None,
2321 })
2322}
2323
2324#[derive(Debug, Deserialize)]
2325struct OAuthRefreshTokenResponse {
2326 access_token: String,
2327 #[serde(default)]
2328 refresh_token: Option<String>,
2329 expires_in: i64,
2330}
2331
2332async fn refresh_google_oauth_token_with_project(
2333 client: &crate::http::client::Client,
2334 token_url: &str,
2335 client_id: &str,
2336 client_secret: &str,
2337 refresh_token: &str,
2338 project_id: &str,
2339 provider_name: &str,
2340) -> Result<AuthCredential> {
2341 let form_body = format!(
2342 "client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token",
2343 percent_encode_component(client_id),
2344 percent_encode_component(client_secret),
2345 percent_encode_component(refresh_token),
2346 );
2347
2348 let request = client
2349 .post(token_url)
2350 .header("Content-Type", "application/x-www-form-urlencoded")
2351 .header("Accept", "application/json")
2352 .body(form_body.into_bytes());
2353
2354 let response = Box::pin(request.send())
2355 .await
2356 .map_err(|e| Error::auth(format!("{provider_name} token refresh failed: {e}")))?;
2357 let status = response.status();
2358 let text = response
2359 .text()
2360 .await
2361 .unwrap_or_else(|_| "<failed to read body>".to_string());
2362 let redacted_text = redact_known_secrets(&text, &[client_secret, refresh_token]);
2363 if !(200..300).contains(&status) {
2364 return Err(Error::auth(format!(
2365 "{provider_name} token refresh failed: {redacted_text}"
2366 )));
2367 }
2368
2369 let oauth_response: OAuthRefreshTokenResponse = serde_json::from_str(&text)
2370 .map_err(|e| Error::auth(format!("Invalid {provider_name} refresh response: {e}")))?;
2371
2372 Ok(AuthCredential::OAuth {
2373 access_token: encode_project_scoped_access_token(&oauth_response.access_token, project_id),
2374 refresh_token: oauth_response
2375 .refresh_token
2376 .unwrap_or_else(|| refresh_token.to_string()),
2377 expires: oauth_expires_at_ms(oauth_response.expires_in),
2378 token_url: None,
2379 client_id: None,
2380 })
2381}
2382
2383async fn refresh_google_gemini_cli_oauth_token(
2384 client: &crate::http::client::Client,
2385 refresh_token: &str,
2386 project_id: &str,
2387) -> Result<AuthCredential> {
2388 refresh_google_oauth_token_with_project(
2389 client,
2390 GOOGLE_GEMINI_CLI_OAUTH_TOKEN_URL,
2391 GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID,
2392 GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET,
2393 refresh_token,
2394 project_id,
2395 "google-gemini-cli",
2396 )
2397 .await
2398}
2399
2400async fn refresh_google_antigravity_oauth_token(
2401 client: &crate::http::client::Client,
2402 refresh_token: &str,
2403 project_id: &str,
2404) -> Result<AuthCredential> {
2405 refresh_google_oauth_token_with_project(
2406 client,
2407 GOOGLE_ANTIGRAVITY_OAUTH_TOKEN_URL,
2408 GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID,
2409 GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET,
2410 refresh_token,
2411 project_id,
2412 "google-antigravity",
2413 )
2414 .await
2415}
2416
2417pub async fn start_kimi_code_device_flow() -> Result<DeviceCodeResponse> {
2419 let client = crate::http::client::Client::new();
2420 start_kimi_code_device_flow_with_client(&client, &kimi_code_oauth_host()).await
2421}
2422
2423async fn start_kimi_code_device_flow_with_client(
2424 client: &crate::http::client::Client,
2425 oauth_host: &str,
2426) -> Result<DeviceCodeResponse> {
2427 let url = kimi_code_endpoint_for_host(oauth_host, KIMI_CODE_DEVICE_AUTHORIZATION_PATH);
2428 let form_body = format!(
2429 "client_id={}",
2430 percent_encode_component(KIMI_CODE_OAUTH_CLIENT_ID)
2431 );
2432 let mut request = client
2433 .post(&url)
2434 .header("Content-Type", "application/x-www-form-urlencoded")
2435 .header("Accept", "application/json")
2436 .body(form_body.into_bytes());
2437 for (name, value) in kimi_common_headers() {
2438 request = request.header(name, value);
2439 }
2440
2441 let response = Box::pin(request.send())
2442 .await
2443 .map_err(|e| Error::auth(format!("Kimi device authorization request failed: {e}")))?;
2444 let status = response.status();
2445 let text = response
2446 .text()
2447 .await
2448 .unwrap_or_else(|_| "<failed to read body>".to_string());
2449 let redacted_text = redact_known_secrets(&text, &[KIMI_CODE_OAUTH_CLIENT_ID]);
2450 if !(200..300).contains(&status) {
2451 return Err(Error::auth(format!(
2452 "Kimi device authorization failed (HTTP {status}): {redacted_text}"
2453 )));
2454 }
2455
2456 serde_json::from_str(&text)
2457 .map_err(|e| Error::auth(format!("Invalid Kimi device authorization response: {e}")))
2458}
2459
2460pub async fn poll_kimi_code_device_flow(device_code: &str) -> DeviceFlowPollResult {
2462 let client = crate::http::client::Client::new();
2463 poll_kimi_code_device_flow_with_client(&client, &kimi_code_oauth_host(), device_code).await
2464}
2465
2466async fn poll_kimi_code_device_flow_with_client(
2467 client: &crate::http::client::Client,
2468 oauth_host: &str,
2469 device_code: &str,
2470) -> DeviceFlowPollResult {
2471 let token_url = kimi_code_endpoint_for_host(oauth_host, KIMI_CODE_TOKEN_PATH);
2472 let form_body = format!(
2473 "client_id={}&device_code={}&grant_type={}",
2474 percent_encode_component(KIMI_CODE_OAUTH_CLIENT_ID),
2475 percent_encode_component(device_code),
2476 percent_encode_component("urn:ietf:params:oauth:grant-type:device_code"),
2477 );
2478 let mut request = client
2479 .post(&token_url)
2480 .header("Content-Type", "application/x-www-form-urlencoded")
2481 .header("Accept", "application/json")
2482 .body(form_body.into_bytes());
2483 for (name, value) in kimi_common_headers() {
2484 request = request.header(name, value);
2485 }
2486
2487 let response = match Box::pin(request.send()).await {
2488 Ok(response) => response,
2489 Err(err) => return DeviceFlowPollResult::Error(format!("Poll request failed: {err}")),
2490 };
2491 let status = response.status();
2492 let text = response
2493 .text()
2494 .await
2495 .unwrap_or_else(|_| "<failed to read body>".to_string());
2496 let json: serde_json::Value = match serde_json::from_str(&text) {
2497 Ok(value) => value,
2498 Err(err) => {
2499 return DeviceFlowPollResult::Error(format!("Invalid poll response JSON: {err}"));
2500 }
2501 };
2502
2503 if let Some(error) = json.get("error").and_then(serde_json::Value::as_str) {
2504 return match error {
2505 "authorization_pending" => DeviceFlowPollResult::Pending,
2506 "slow_down" => DeviceFlowPollResult::SlowDown,
2507 "expired_token" => DeviceFlowPollResult::Expired,
2508 "access_denied" => DeviceFlowPollResult::AccessDenied,
2509 other => {
2510 let detail = json
2511 .get("error_description")
2512 .and_then(serde_json::Value::as_str)
2513 .unwrap_or("unknown error");
2514 DeviceFlowPollResult::Error(format!("Kimi device flow error: {other}: {detail}"))
2515 }
2516 };
2517 }
2518
2519 if !(200..300).contains(&status) {
2520 return DeviceFlowPollResult::Error(format!(
2521 "Kimi device flow polling failed (HTTP {status}): {}",
2522 redact_known_secrets(&text, &[device_code]),
2523 ));
2524 }
2525
2526 let oauth_response: OAuthTokenResponse = match serde_json::from_value(json) {
2527 Ok(response) => response,
2528 Err(err) => {
2529 return DeviceFlowPollResult::Error(format!(
2530 "Invalid Kimi token response payload: {err}"
2531 ));
2532 }
2533 };
2534
2535 DeviceFlowPollResult::Success(AuthCredential::OAuth {
2536 access_token: oauth_response.access_token,
2537 refresh_token: oauth_response.refresh_token,
2538 expires: oauth_expires_at_ms(oauth_response.expires_in),
2539 token_url: Some(token_url),
2540 client_id: Some(KIMI_CODE_OAUTH_CLIENT_ID.to_string()),
2541 })
2542}
2543
2544async fn refresh_kimi_code_oauth_token(
2545 client: &crate::http::client::Client,
2546 token_url: &str,
2547 refresh_token: &str,
2548) -> Result<AuthCredential> {
2549 let form_body = format!(
2550 "client_id={}&grant_type=refresh_token&refresh_token={}",
2551 percent_encode_component(KIMI_CODE_OAUTH_CLIENT_ID),
2552 percent_encode_component(refresh_token),
2553 );
2554 let mut request = client
2555 .post(token_url)
2556 .header("Content-Type", "application/x-www-form-urlencoded")
2557 .header("Accept", "application/json")
2558 .body(form_body.into_bytes());
2559 for (name, value) in kimi_common_headers() {
2560 request = request.header(name, value);
2561 }
2562
2563 let response = Box::pin(request.send())
2564 .await
2565 .map_err(|e| Error::auth(format!("Kimi token refresh failed: {e}")))?;
2566 let status = response.status();
2567 let text = response
2568 .text()
2569 .await
2570 .unwrap_or_else(|_| "<failed to read body>".to_string());
2571 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
2572 if !(200..300).contains(&status) {
2573 return Err(Error::auth(format!(
2574 "Kimi token refresh failed (HTTP {status}): {redacted_text}"
2575 )));
2576 }
2577
2578 let oauth_response: OAuthRefreshTokenResponse = serde_json::from_str(&text)
2579 .map_err(|e| Error::auth(format!("Invalid Kimi refresh response: {e}")))?;
2580
2581 Ok(AuthCredential::OAuth {
2582 access_token: oauth_response.access_token,
2583 refresh_token: oauth_response
2584 .refresh_token
2585 .unwrap_or_else(|| refresh_token.to_string()),
2586 expires: oauth_expires_at_ms(oauth_response.expires_in),
2587 token_url: Some(token_url.to_string()),
2588 client_id: Some(KIMI_CODE_OAUTH_CLIENT_ID.to_string()),
2589 })
2590}
2591
2592pub fn start_extension_oauth(
2594 provider_name: &str,
2595 config: &crate::models::OAuthConfig,
2596) -> Result<OAuthStartInfo> {
2597 let (verifier, challenge) = generate_pkce();
2598 let scopes = config.scopes.join(" ");
2599
2600 let mut params: Vec<(&str, &str)> = vec![
2601 ("client_id", &config.client_id),
2602 ("response_type", "code"),
2603 ("scope", &scopes),
2604 ("code_challenge", &challenge),
2605 ("code_challenge_method", "S256"),
2606 ("state", &verifier),
2607 ];
2608
2609 let redirect_uri_ref = config.redirect_uri.as_deref();
2610 if let Some(uri) = redirect_uri_ref {
2611 params.push(("redirect_uri", uri));
2612 }
2613
2614 let url = build_url_with_query(&config.auth_url, ¶ms);
2615
2616 Ok(OAuthStartInfo {
2617 provider: provider_name.to_string(),
2618 url,
2619 verifier,
2620 instructions: Some(
2621 "Open the URL, complete login, then paste the callback URL or authorization code."
2622 .to_string(),
2623 ),
2624 })
2625}
2626
2627pub async fn complete_extension_oauth(
2629 config: &crate::models::OAuthConfig,
2630 code_input: &str,
2631 verifier: &str,
2632) -> Result<AuthCredential> {
2633 let (code, state) = parse_oauth_code_input(code_input);
2634
2635 let Some(code) = code else {
2636 return Err(Error::auth("Missing authorization code".to_string()));
2637 };
2638
2639 let state = state.unwrap_or_else(|| verifier.to_string());
2640 if state != verifier {
2641 return Err(Error::auth("State mismatch".to_string()));
2642 }
2643
2644 let client = crate::http::client::Client::new();
2645
2646 let mut body = serde_json::json!({
2647 "grant_type": "authorization_code",
2648 "client_id": config.client_id,
2649 "code": code,
2650 "state": state,
2651 "code_verifier": verifier,
2652 });
2653
2654 if let Some(ref redirect_uri) = config.redirect_uri {
2655 body["redirect_uri"] = serde_json::Value::String(redirect_uri.clone());
2656 }
2657
2658 let request = client.post(&config.token_url).json(&body)?;
2659
2660 let response = Box::pin(request.send())
2661 .await
2662 .map_err(|e| Error::auth(format!("Token exchange failed: {e}")))?;
2663
2664 let status = response.status();
2665 let text = response
2666 .text()
2667 .await
2668 .unwrap_or_else(|_| "<failed to read body>".to_string());
2669 let redacted_text = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
2670
2671 if !(200..300).contains(&status) {
2672 return Err(Error::auth(format!(
2673 "Token exchange failed: {redacted_text}"
2674 )));
2675 }
2676
2677 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2678 .map_err(|e| Error::auth(format!("Invalid token response: {e}")))?;
2679
2680 Ok(AuthCredential::OAuth {
2681 access_token: oauth_response.access_token,
2682 refresh_token: oauth_response.refresh_token,
2683 expires: oauth_expires_at_ms(oauth_response.expires_in),
2684 token_url: Some(config.token_url.clone()),
2685 client_id: Some(config.client_id.clone()),
2686 })
2687}
2688
2689async fn refresh_extension_oauth_token(
2691 client: &crate::http::client::Client,
2692 config: &crate::models::OAuthConfig,
2693 refresh_token: &str,
2694) -> Result<AuthCredential> {
2695 let request = client.post(&config.token_url).json(&serde_json::json!({
2696 "grant_type": "refresh_token",
2697 "client_id": config.client_id,
2698 "refresh_token": refresh_token,
2699 }))?;
2700
2701 let response = Box::pin(request.send())
2702 .await
2703 .map_err(|e| Error::auth(format!("Extension OAuth token refresh failed: {e}")))?;
2704
2705 let status = response.status();
2706 let text = response
2707 .text()
2708 .await
2709 .unwrap_or_else(|_| "<failed to read body>".to_string());
2710 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
2711
2712 if !(200..300).contains(&status) {
2713 return Err(Error::auth(format!(
2714 "Extension OAuth token refresh failed: {redacted_text}"
2715 )));
2716 }
2717
2718 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2719 .map_err(|e| Error::auth(format!("Invalid refresh response: {e}")))?;
2720
2721 Ok(AuthCredential::OAuth {
2722 access_token: oauth_response.access_token,
2723 refresh_token: oauth_response.refresh_token,
2724 expires: oauth_expires_at_ms(oauth_response.expires_in),
2725 token_url: Some(config.token_url.clone()),
2726 client_id: Some(config.client_id.clone()),
2727 })
2728}
2729
2730async fn refresh_self_contained_oauth_token(
2736 client: &crate::http::client::Client,
2737 token_url: &str,
2738 oauth_client_id: &str,
2739 refresh_token: &str,
2740 provider: &str,
2741) -> Result<AuthCredential> {
2742 let request = client.post(token_url).json(&serde_json::json!({
2743 "grant_type": "refresh_token",
2744 "client_id": oauth_client_id,
2745 "refresh_token": refresh_token,
2746 }))?;
2747
2748 let response = Box::pin(request.send())
2749 .await
2750 .map_err(|e| Error::auth(format!("{provider} token refresh failed: {e}")))?;
2751
2752 let status = response.status();
2753 let text = response
2754 .text()
2755 .await
2756 .unwrap_or_else(|_| "<failed to read body>".to_string());
2757 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
2758
2759 if !(200..300).contains(&status) {
2760 return Err(Error::auth(format!(
2761 "{provider} token refresh failed (HTTP {status}): {redacted_text}"
2762 )));
2763 }
2764
2765 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2766 .map_err(|e| Error::auth(format!("Invalid refresh response from {provider}: {e}")))?;
2767
2768 Ok(AuthCredential::OAuth {
2769 access_token: oauth_response.access_token,
2770 refresh_token: oauth_response.refresh_token,
2771 expires: oauth_expires_at_ms(oauth_response.expires_in),
2772 token_url: Some(token_url.to_string()),
2773 client_id: Some(oauth_client_id.to_string()),
2774 })
2775}
2776
2777pub fn start_copilot_browser_oauth(config: &CopilotOAuthConfig) -> Result<OAuthStartInfo> {
2785 if config.client_id.is_empty() {
2786 return Err(Error::auth(
2787 "GitHub Copilot OAuth requires a client_id. Set GITHUB_COPILOT_CLIENT_ID or \
2788 configure the GitHub App in your settings."
2789 .to_string(),
2790 ));
2791 }
2792
2793 let (verifier, challenge) = generate_pkce();
2794
2795 let auth_url = if config.github_base_url == "https://github.com" {
2796 GITHUB_OAUTH_AUTHORIZE_URL.to_string()
2797 } else {
2798 format!(
2799 "{}/login/oauth/authorize",
2800 trim_trailing_slash(&config.github_base_url)
2801 )
2802 };
2803
2804 let url = build_url_with_query(
2805 &auth_url,
2806 &[
2807 ("client_id", &config.client_id),
2808 ("response_type", "code"),
2809 ("scope", &config.scopes),
2810 ("code_challenge", &challenge),
2811 ("code_challenge_method", "S256"),
2812 ("state", &verifier),
2813 ],
2814 );
2815
2816 Ok(OAuthStartInfo {
2817 provider: "github-copilot".to_string(),
2818 url,
2819 verifier,
2820 instructions: Some(
2821 "Open the URL in your browser to authorize GitHub Copilot access, \
2822 then paste the callback URL or authorization code."
2823 .to_string(),
2824 ),
2825 })
2826}
2827
2828pub async fn complete_copilot_browser_oauth(
2830 config: &CopilotOAuthConfig,
2831 code_input: &str,
2832 verifier: &str,
2833) -> Result<AuthCredential> {
2834 let (code, state) = parse_oauth_code_input(code_input);
2835
2836 let Some(code) = code else {
2837 return Err(Error::auth(
2838 "Missing authorization code. Paste the full callback URL or just the code parameter."
2839 .to_string(),
2840 ));
2841 };
2842
2843 let state = state.unwrap_or_else(|| verifier.to_string());
2844 if state != verifier {
2845 return Err(Error::auth("State mismatch".to_string()));
2846 }
2847
2848 let token_url_str = if config.github_base_url == "https://github.com" {
2849 GITHUB_OAUTH_TOKEN_URL.to_string()
2850 } else {
2851 format!(
2852 "{}/login/oauth/access_token",
2853 trim_trailing_slash(&config.github_base_url)
2854 )
2855 };
2856
2857 let client = crate::http::client::Client::new();
2858 let request = client
2859 .post(&token_url_str)
2860 .header("Accept", "application/json")
2861 .json(&serde_json::json!({
2862 "grant_type": "authorization_code",
2863 "client_id": config.client_id,
2864 "code": code,
2865 "state": state,
2866 "code_verifier": verifier,
2867 }))?;
2868
2869 let response = Box::pin(request.send())
2870 .await
2871 .map_err(|e| Error::auth(format!("GitHub token exchange failed: {e}")))?;
2872
2873 let status = response.status();
2874 let text = response
2875 .text()
2876 .await
2877 .unwrap_or_else(|_| "<failed to read body>".to_string());
2878 let redacted = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
2879
2880 if !(200..300).contains(&status) {
2881 return Err(Error::auth(copilot_diagnostic(
2882 &format!("Token exchange failed (HTTP {status})"),
2883 &redacted,
2884 )));
2885 }
2886
2887 let mut cred = parse_github_token_response(&text)?;
2888 if let AuthCredential::OAuth {
2890 ref mut token_url,
2891 ref mut client_id,
2892 ..
2893 } = cred
2894 {
2895 *token_url = Some(token_url_str.clone());
2896 *client_id = Some(config.client_id.clone());
2897 }
2898 Ok(cred)
2899}
2900
2901pub async fn start_copilot_device_flow(config: &CopilotOAuthConfig) -> Result<DeviceCodeResponse> {
2906 if config.client_id.is_empty() {
2907 return Err(Error::auth(
2908 "GitHub Copilot device flow requires a client_id. Set GITHUB_COPILOT_CLIENT_ID or \
2909 configure the GitHub App in your settings."
2910 .to_string(),
2911 ));
2912 }
2913
2914 let device_url = if config.github_base_url == "https://github.com" {
2915 GITHUB_DEVICE_CODE_URL.to_string()
2916 } else {
2917 format!(
2918 "{}/login/device/code",
2919 trim_trailing_slash(&config.github_base_url)
2920 )
2921 };
2922
2923 let client = crate::http::client::Client::new();
2924 let request = client
2925 .post(&device_url)
2926 .header("Accept", "application/json")
2927 .json(&serde_json::json!({
2928 "client_id": config.client_id,
2929 "scope": config.scopes,
2930 }))?;
2931
2932 let response = Box::pin(request.send())
2933 .await
2934 .map_err(|e| Error::auth(format!("GitHub device code request failed: {e}")))?;
2935
2936 let status = response.status();
2937 let text = response
2938 .text()
2939 .await
2940 .unwrap_or_else(|_| "<failed to read body>".to_string());
2941
2942 if !(200..300).contains(&status) {
2943 return Err(Error::auth(copilot_diagnostic(
2944 &format!("Device code request failed (HTTP {status})"),
2945 &redact_known_secrets(&text, &[]),
2946 )));
2947 }
2948
2949 serde_json::from_str(&text).map_err(|e| {
2950 Error::auth(format!(
2951 "Invalid device code response: {e}. \
2952 Ensure the GitHub App has the Device Flow enabled."
2953 ))
2954 })
2955}
2956
2957pub async fn poll_copilot_device_flow(
2962 config: &CopilotOAuthConfig,
2963 device_code: &str,
2964) -> DeviceFlowPollResult {
2965 let token_url = if config.github_base_url == "https://github.com" {
2966 GITHUB_OAUTH_TOKEN_URL.to_string()
2967 } else {
2968 format!(
2969 "{}/login/oauth/access_token",
2970 trim_trailing_slash(&config.github_base_url)
2971 )
2972 };
2973
2974 let client = crate::http::client::Client::new();
2975 let request = match client
2976 .post(&token_url)
2977 .header("Accept", "application/json")
2978 .json(&serde_json::json!({
2979 "client_id": config.client_id,
2980 "device_code": device_code,
2981 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
2982 })) {
2983 Ok(r) => r,
2984 Err(e) => return DeviceFlowPollResult::Error(format!("Request build failed: {e}")),
2985 };
2986
2987 let response = match Box::pin(request.send()).await {
2988 Ok(r) => r,
2989 Err(e) => return DeviceFlowPollResult::Error(format!("Poll request failed: {e}")),
2990 };
2991
2992 let text = response
2993 .text()
2994 .await
2995 .unwrap_or_else(|_| "<failed to read body>".to_string());
2996
2997 let json: serde_json::Value = match serde_json::from_str(&text) {
2999 Ok(v) => v,
3000 Err(e) => {
3001 return DeviceFlowPollResult::Error(format!("Invalid poll response: {e}"));
3002 }
3003 };
3004
3005 if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
3006 return match error {
3007 "authorization_pending" => DeviceFlowPollResult::Pending,
3008 "slow_down" => DeviceFlowPollResult::SlowDown,
3009 "expired_token" => DeviceFlowPollResult::Expired,
3010 "access_denied" => DeviceFlowPollResult::AccessDenied,
3011 other => DeviceFlowPollResult::Error(format!(
3012 "GitHub device flow error: {other}. {}",
3013 json.get("error_description")
3014 .and_then(|v| v.as_str())
3015 .unwrap_or("Check your GitHub App configuration.")
3016 )),
3017 };
3018 }
3019
3020 match parse_github_token_response(&text) {
3021 Ok(cred) => DeviceFlowPollResult::Success(cred),
3022 Err(e) => DeviceFlowPollResult::Error(e.to_string()),
3023 }
3024}
3025
3026fn parse_github_token_response(text: &str) -> Result<AuthCredential> {
3031 let json: serde_json::Value =
3032 serde_json::from_str(text).map_err(|e| Error::auth(format!("Invalid token JSON: {e}")))?;
3033
3034 let access_token = json
3035 .get("access_token")
3036 .and_then(|v| v.as_str())
3037 .ok_or_else(|| Error::auth("Missing access_token in GitHub response".to_string()))?
3038 .to_string();
3039
3040 let refresh_token = json
3042 .get("refresh_token")
3043 .and_then(|v| v.as_str())
3044 .unwrap_or("")
3045 .to_string();
3046
3047 let expires = json
3048 .get("expires_in")
3049 .and_then(serde_json::Value::as_i64)
3050 .map_or_else(
3051 || {
3052 oauth_expires_at_ms(365 * 24 * 3600)
3054 },
3055 oauth_expires_at_ms,
3056 );
3057
3058 Ok(AuthCredential::OAuth {
3059 access_token,
3060 refresh_token,
3061 expires,
3062 token_url: None,
3065 client_id: None,
3066 })
3067}
3068
3069fn copilot_diagnostic(summary: &str, detail: &str) -> String {
3071 format!(
3072 "{summary}: {detail}\n\
3073 Troubleshooting:\n\
3074 - Verify the GitHub App client_id is correct\n\
3075 - Ensure your GitHub account has an active Copilot subscription\n\
3076 - For GitHub Enterprise, set the correct base URL\n\
3077 - Check https://github.com/settings/applications for app authorization status"
3078 )
3079}
3080
3081pub fn start_gitlab_oauth(config: &GitLabOAuthConfig) -> Result<OAuthStartInfo> {
3088 if config.client_id.is_empty() {
3089 return Err(Error::auth(
3090 "GitLab OAuth requires a client_id. Create an application at \
3091 Settings > Applications in your GitLab instance."
3092 .to_string(),
3093 ));
3094 }
3095
3096 let (verifier, challenge) = generate_pkce();
3097 let base = trim_trailing_slash(&config.base_url);
3098 let auth_url = format!("{base}{GITLAB_OAUTH_AUTHORIZE_PATH}");
3099
3100 let mut params: Vec<(&str, &str)> = vec![
3101 ("client_id", &config.client_id),
3102 ("response_type", "code"),
3103 ("scope", &config.scopes),
3104 ("code_challenge", &challenge),
3105 ("code_challenge_method", "S256"),
3106 ("state", &verifier),
3107 ];
3108
3109 let redirect_ref = config.redirect_uri.as_deref();
3110 if let Some(uri) = redirect_ref {
3111 params.push(("redirect_uri", uri));
3112 }
3113
3114 let url = build_url_with_query(&auth_url, ¶ms);
3115
3116 Ok(OAuthStartInfo {
3117 provider: "gitlab".to_string(),
3118 url,
3119 verifier,
3120 instructions: Some(format!(
3121 "Open the URL to authorize GitLab access on {base}, \
3122 then paste the callback URL or authorization code."
3123 )),
3124 })
3125}
3126
3127pub async fn complete_gitlab_oauth(
3129 config: &GitLabOAuthConfig,
3130 code_input: &str,
3131 verifier: &str,
3132) -> Result<AuthCredential> {
3133 let (code, state) = parse_oauth_code_input(code_input);
3134
3135 let Some(code) = code else {
3136 return Err(Error::auth(
3137 "Missing authorization code. Paste the full callback URL or just the code parameter."
3138 .to_string(),
3139 ));
3140 };
3141
3142 let state = state.unwrap_or_else(|| verifier.to_string());
3143 if state != verifier {
3144 return Err(Error::auth("State mismatch".to_string()));
3145 }
3146 let base = trim_trailing_slash(&config.base_url);
3147 let token_url = format!("{base}{GITLAB_OAUTH_TOKEN_PATH}");
3148
3149 let client = crate::http::client::Client::new();
3150
3151 let mut body = serde_json::json!({
3152 "grant_type": "authorization_code",
3153 "client_id": config.client_id,
3154 "code": code,
3155 "state": state,
3156 "code_verifier": verifier,
3157 });
3158
3159 if let Some(ref redirect_uri) = config.redirect_uri {
3160 body["redirect_uri"] = serde_json::Value::String(redirect_uri.clone());
3161 }
3162
3163 let request = client
3164 .post(&token_url)
3165 .header("Accept", "application/json")
3166 .json(&body)?;
3167
3168 let response = Box::pin(request.send())
3169 .await
3170 .map_err(|e| Error::auth(format!("GitLab token exchange failed: {e}")))?;
3171
3172 let status = response.status();
3173 let text = response
3174 .text()
3175 .await
3176 .unwrap_or_else(|_| "<failed to read body>".to_string());
3177 let redacted = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
3178
3179 if !(200..300).contains(&status) {
3180 return Err(Error::auth(gitlab_diagnostic(
3181 &config.base_url,
3182 &format!("Token exchange failed (HTTP {status})"),
3183 &redacted,
3184 )));
3185 }
3186
3187 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text).map_err(|e| {
3188 Error::auth(gitlab_diagnostic(
3189 &config.base_url,
3190 &format!("Invalid token response: {e}"),
3191 &redacted,
3192 ))
3193 })?;
3194
3195 let base = trim_trailing_slash(&config.base_url);
3196 Ok(AuthCredential::OAuth {
3197 access_token: oauth_response.access_token,
3198 refresh_token: oauth_response.refresh_token,
3199 expires: oauth_expires_at_ms(oauth_response.expires_in),
3200 token_url: Some(format!("{base}{GITLAB_OAUTH_TOKEN_PATH}")),
3201 client_id: Some(config.client_id.clone()),
3202 })
3203}
3204
3205fn gitlab_diagnostic(base_url: &str, summary: &str, detail: &str) -> String {
3207 format!(
3208 "{summary}: {detail}\n\
3209 Troubleshooting:\n\
3210 - Verify the application client_id matches your GitLab application\n\
3211 - Check Settings > Applications on {base_url}\n\
3212 - Ensure the redirect URI matches your application configuration\n\
3213 - For self-hosted GitLab, verify the base URL is correct ({base_url})"
3214 )
3215}
3216
3217fn trim_trailing_slash(url: &str) -> &str {
3235 url.trim_end_matches('/')
3236}
3237
3238#[derive(Debug, Deserialize)]
3239struct OAuthTokenResponse {
3240 access_token: String,
3241 refresh_token: String,
3242 expires_in: i64,
3243}
3244
3245fn oauth_expires_at_ms(expires_in_seconds: i64) -> i64 {
3246 const SAFETY_MARGIN_MS: i64 = 5 * 60 * 1000;
3247 let now_ms = chrono::Utc::now().timestamp_millis();
3248 let expires_ms = expires_in_seconds.saturating_mul(1000);
3249 now_ms
3250 .saturating_add(expires_ms)
3251 .saturating_sub(SAFETY_MARGIN_MS)
3252}
3253
3254fn generate_pkce() -> (String, String) {
3255 let uuid1 = uuid::Uuid::new_v4();
3256 let uuid2 = uuid::Uuid::new_v4();
3257 let mut random = [0u8; 32];
3258 random[..16].copy_from_slice(uuid1.as_bytes());
3259 random[16..].copy_from_slice(uuid2.as_bytes());
3260
3261 let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random);
3262 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
3263 .encode(sha2::Sha256::digest(verifier.as_bytes()));
3264 (verifier, challenge)
3265}
3266
3267fn parse_oauth_code_input(input: &str) -> (Option<String>, Option<String>) {
3268 let value = input.trim();
3269 if value.is_empty() {
3270 return (None, None);
3271 }
3272
3273 if let Some((_, query)) = value.split_once('?') {
3274 let query = query.split('#').next().unwrap_or(query);
3275 let pairs = parse_query_pairs(query);
3276 let code = pairs
3277 .iter()
3278 .find_map(|(k, v)| (k == "code").then(|| v.clone()));
3279 let state = pairs
3280 .iter()
3281 .find_map(|(k, v)| (k == "state").then(|| v.clone()));
3282 return (code, state);
3283 }
3284
3285 if let Some((code, state)) = value.split_once('#') {
3286 let code = code.trim();
3287 let state = state.trim();
3288 return (
3289 (!code.is_empty()).then(|| code.to_string()),
3290 (!state.is_empty()).then(|| state.to_string()),
3291 );
3292 }
3293
3294 (Some(value.to_string()), None)
3295}
3296
3297fn lock_file(file: File, timeout: Duration) -> Result<LockedFile> {
3298 let start = Instant::now();
3299 let mut attempt: u32 = 0;
3300 loop {
3301 match FileExt::try_lock_exclusive(&file) {
3302 Ok(true) => return Ok(LockedFile { file }),
3303 Ok(false) => {} Err(e) => {
3305 return Err(Error::auth(format!("Failed to lock auth file: {e}")));
3306 }
3307 }
3308
3309 if start.elapsed() >= timeout {
3310 return Err(Error::auth("Timed out waiting for auth lock".to_string()));
3311 }
3312
3313 let base_ms: u64 = 10;
3314 let cap_ms: u64 = 500;
3315 let sleep_ms = base_ms
3316 .checked_shl(attempt.min(5))
3317 .unwrap_or(cap_ms)
3318 .min(cap_ms);
3319 let jitter = u64::from(start.elapsed().subsec_nanos()) % (sleep_ms / 2 + 1);
3320 let delay = sleep_ms / 2 + jitter;
3321 std::thread::sleep(Duration::from_millis(delay));
3322 attempt = attempt.saturating_add(1);
3323 }
3324}
3325
3326struct LockedFile {
3328 file: File,
3329}
3330
3331impl LockedFile {
3332 const fn as_file_mut(&mut self) -> &mut File {
3333 &mut self.file
3334 }
3335}
3336
3337impl Drop for LockedFile {
3338 fn drop(&mut self) {
3339 let _ = FileExt::unlock(&self.file);
3340 }
3341}
3342
3343pub fn load_default_auth(path: &Path) -> Result<AuthStorage> {
3345 AuthStorage::load(path.to_path_buf())
3346}
3347
3348#[cfg(test)]
3349mod tests {
3350 use super::*;
3351 use std::io::{Read, Write};
3352 use std::net::TcpListener;
3353 use std::time::Duration;
3354
3355 fn next_token() -> String {
3356 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3357 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
3358 .to_string()
3359 }
3360
3361 #[allow(clippy::needless_pass_by_value)]
3362 fn log_test_event(test_name: &str, event: &str, data: serde_json::Value) {
3363 let timestamp_ms = std::time::SystemTime::now()
3364 .duration_since(std::time::UNIX_EPOCH)
3365 .expect("clock should be after epoch")
3366 .as_millis();
3367 let entry = serde_json::json!({
3368 "schema": "pi.test.auth_event.v1",
3369 "test": test_name,
3370 "event": event,
3371 "timestamp_ms": timestamp_ms,
3372 "data": data,
3373 });
3374 eprintln!(
3375 "JSONL: {}",
3376 serde_json::to_string(&entry).expect("serialize auth test event")
3377 );
3378 }
3379
3380 fn spawn_json_server(status_code: u16, body: &str) -> String {
3381 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
3382 let addr = listener.local_addr().expect("local addr");
3383 let body = body.to_string();
3384
3385 std::thread::spawn(move || {
3386 let (mut socket, _) = listener.accept().expect("accept");
3387 socket
3388 .set_read_timeout(Some(Duration::from_secs(2)))
3389 .expect("set read timeout");
3390
3391 let mut chunk = [0_u8; 4096];
3392 let _ = socket.read(&mut chunk);
3393
3394 let reason = match status_code {
3395 401 => "Unauthorized",
3396 500 => "Internal Server Error",
3397 _ => "OK",
3398 };
3399 let response = format!(
3400 "HTTP/1.1 {status_code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3401 body.len()
3402 );
3403 socket
3404 .write_all(response.as_bytes())
3405 .expect("write response");
3406 socket.flush().expect("flush response");
3407 });
3408
3409 format!("http://{addr}/token")
3410 }
3411
3412 fn spawn_oauth_host_server(status_code: u16, body: &str) -> String {
3413 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
3414 let addr = listener.local_addr().expect("local addr");
3415 let body = body.to_string();
3416
3417 std::thread::spawn(move || {
3418 let (mut socket, _) = listener.accept().expect("accept");
3419 socket
3420 .set_read_timeout(Some(Duration::from_secs(2)))
3421 .expect("set read timeout");
3422
3423 let mut chunk = [0_u8; 4096];
3424 let _ = socket.read(&mut chunk);
3425
3426 let reason = match status_code {
3427 400 => "Bad Request",
3428 401 => "Unauthorized",
3429 403 => "Forbidden",
3430 500 => "Internal Server Error",
3431 _ => "OK",
3432 };
3433 let response = format!(
3434 "HTTP/1.1 {status_code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3435 body.len()
3436 );
3437 socket
3438 .write_all(response.as_bytes())
3439 .expect("write response");
3440 socket.flush().expect("flush response");
3441 });
3442
3443 format!("http://{addr}")
3444 }
3445
3446 #[test]
3447 fn test_google_project_id_from_gcloud_config_parses_core_project() {
3448 let dir = tempfile::tempdir().expect("tmpdir");
3449 let gcloud_dir = dir.path().join("gcloud");
3450 let configs_dir = gcloud_dir.join("configurations");
3451 std::fs::create_dir_all(&configs_dir).expect("mkdir configurations");
3452 std::fs::write(
3453 configs_dir.join("config_default"),
3454 "[core]\nproject = my-proj\n",
3455 )
3456 .expect("write config_default");
3457
3458 let project = google_project_id_from_gcloud_config_with_env_lookup(|key| match key {
3459 "CLOUDSDK_CONFIG" => Some(gcloud_dir.to_string_lossy().to_string()),
3460 _ => None,
3461 });
3462
3463 assert_eq!(project.as_deref(), Some("my-proj"));
3464 }
3465
3466 #[test]
3467 fn test_auth_storage_load_missing_file_starts_empty() {
3468 let dir = tempfile::tempdir().expect("tmpdir");
3469 let auth_path = dir.path().join("missing-auth.json");
3470 assert!(!auth_path.exists());
3471
3472 let loaded = AuthStorage::load(auth_path.clone()).expect("load");
3473 assert!(loaded.entries.is_empty());
3474 assert_eq!(loaded.path, auth_path);
3475 }
3476
3477 #[test]
3478 fn test_auth_storage_api_key_round_trip() {
3479 let dir = tempfile::tempdir().expect("tmpdir");
3480 let auth_path = dir.path().join("auth.json");
3481
3482 {
3483 let mut auth = AuthStorage {
3484 path: auth_path.clone(),
3485 entries: HashMap::new(),
3486 };
3487 auth.set(
3488 "openai",
3489 AuthCredential::ApiKey {
3490 key: "stored-openai-key".to_string(),
3491 },
3492 );
3493 auth.save().expect("save");
3494 }
3495
3496 let loaded = AuthStorage::load(auth_path).expect("load");
3497 assert_eq!(
3498 loaded.api_key("openai").as_deref(),
3499 Some("stored-openai-key")
3500 );
3501 }
3502
3503 #[test]
3504 fn test_openai_oauth_url_generation() {
3505 let test_name = "test_openai_oauth_url_generation";
3506 log_test_event(
3507 test_name,
3508 "test_start",
3509 serde_json::json!({ "provider": "openai", "mode": "api_key" }),
3510 );
3511
3512 let env_keys = env_keys_for_provider("openai");
3513 assert!(
3514 env_keys.contains(&"OPENAI_API_KEY"),
3515 "expected OPENAI_API_KEY in env key candidates"
3516 );
3517 log_test_event(
3518 test_name,
3519 "url_generated",
3520 serde_json::json!({
3521 "provider": "openai",
3522 "flow_type": "api_key",
3523 "env_keys": env_keys,
3524 }),
3525 );
3526 log_test_event(
3527 test_name,
3528 "test_end",
3529 serde_json::json!({ "status": "pass" }),
3530 );
3531 }
3532
3533 #[test]
3534 fn test_openai_token_exchange() {
3535 let test_name = "test_openai_token_exchange";
3536 log_test_event(
3537 test_name,
3538 "test_start",
3539 serde_json::json!({ "provider": "openai", "mode": "api_key_storage" }),
3540 );
3541
3542 let dir = tempfile::tempdir().expect("tmpdir");
3543 let auth_path = dir.path().join("auth.json");
3544 let mut auth = AuthStorage::load(auth_path.clone()).expect("load auth");
3545 auth.set(
3546 "openai",
3547 AuthCredential::ApiKey {
3548 key: "openai-key-test".to_string(),
3549 },
3550 );
3551 auth.save().expect("save auth");
3552
3553 let reloaded = AuthStorage::load(auth_path).expect("reload auth");
3554 assert_eq!(
3555 reloaded.api_key("openai").as_deref(),
3556 Some("openai-key-test")
3557 );
3558 log_test_event(
3559 test_name,
3560 "token_exchanged",
3561 serde_json::json!({
3562 "provider": "openai",
3563 "flow_type": "api_key",
3564 "persisted": true,
3565 }),
3566 );
3567 log_test_event(
3568 test_name,
3569 "test_end",
3570 serde_json::json!({ "status": "pass" }),
3571 );
3572 }
3573
3574 #[test]
3575 fn test_google_oauth_url_generation() {
3576 let test_name = "test_google_oauth_url_generation";
3577 log_test_event(
3578 test_name,
3579 "test_start",
3580 serde_json::json!({ "provider": "google", "mode": "api_key" }),
3581 );
3582
3583 let env_keys = env_keys_for_provider("google");
3584 assert!(
3585 env_keys.contains(&"GOOGLE_API_KEY"),
3586 "expected GOOGLE_API_KEY in env key candidates"
3587 );
3588 assert!(
3589 env_keys.contains(&"GEMINI_API_KEY"),
3590 "expected GEMINI_API_KEY alias in env key candidates"
3591 );
3592 log_test_event(
3593 test_name,
3594 "url_generated",
3595 serde_json::json!({
3596 "provider": "google",
3597 "flow_type": "api_key",
3598 "env_keys": env_keys,
3599 }),
3600 );
3601 log_test_event(
3602 test_name,
3603 "test_end",
3604 serde_json::json!({ "status": "pass" }),
3605 );
3606 }
3607
3608 #[test]
3609 fn test_google_token_exchange() {
3610 let test_name = "test_google_token_exchange";
3611 log_test_event(
3612 test_name,
3613 "test_start",
3614 serde_json::json!({ "provider": "google", "mode": "api_key_storage" }),
3615 );
3616
3617 let dir = tempfile::tempdir().expect("tmpdir");
3618 let auth_path = dir.path().join("auth.json");
3619 let mut auth = AuthStorage::load(auth_path.clone()).expect("load auth");
3620 auth.set(
3621 "google",
3622 AuthCredential::ApiKey {
3623 key: "google-key-test".to_string(),
3624 },
3625 );
3626 auth.save().expect("save auth");
3627
3628 let reloaded = AuthStorage::load(auth_path).expect("reload auth");
3629 assert_eq!(
3630 reloaded.api_key("google").as_deref(),
3631 Some("google-key-test")
3632 );
3633 assert_eq!(
3634 reloaded
3635 .resolve_api_key_with_env_lookup("gemini", None, |_| None)
3636 .as_deref(),
3637 Some("google-key-test")
3638 );
3639 log_test_event(
3640 test_name,
3641 "token_exchanged",
3642 serde_json::json!({
3643 "provider": "google",
3644 "flow_type": "api_key",
3645 "has_refresh": false,
3646 }),
3647 );
3648 log_test_event(
3649 test_name,
3650 "test_end",
3651 serde_json::json!({ "status": "pass" }),
3652 );
3653 }
3654
3655 #[test]
3656 fn test_resolve_api_key_precedence_override_env_stored() {
3657 let dir = tempfile::tempdir().expect("tmpdir");
3658 let auth_path = dir.path().join("auth.json");
3659 let mut auth = AuthStorage {
3660 path: auth_path,
3661 entries: HashMap::new(),
3662 };
3663 auth.set(
3664 "openai",
3665 AuthCredential::ApiKey {
3666 key: "stored-openai-key".to_string(),
3667 },
3668 );
3669
3670 let env_value = "env-openai-key".to_string();
3671
3672 let override_resolved =
3673 auth.resolve_api_key_with_env_lookup("openai", Some("override-key"), |_| {
3674 Some(env_value.clone())
3675 });
3676 assert_eq!(override_resolved.as_deref(), Some("override-key"));
3677
3678 let env_resolved =
3679 auth.resolve_api_key_with_env_lookup("openai", None, |_| Some(env_value.clone()));
3680 assert_eq!(env_resolved.as_deref(), Some("env-openai-key"));
3681
3682 let stored_resolved = auth.resolve_api_key_with_env_lookup("openai", None, |_| None);
3683 assert_eq!(stored_resolved.as_deref(), Some("stored-openai-key"));
3684 }
3685
3686 #[test]
3687 fn test_resolve_api_key_prefers_stored_oauth_over_env() {
3688 let dir = tempfile::tempdir().expect("tmpdir");
3689 let auth_path = dir.path().join("auth.json");
3690 let mut auth = AuthStorage {
3691 path: auth_path,
3692 entries: HashMap::new(),
3693 };
3694 let now = chrono::Utc::now().timestamp_millis();
3695 auth.set(
3696 "anthropic",
3697 AuthCredential::OAuth {
3698 access_token: "stored-oauth-token".to_string(),
3699 refresh_token: "refresh-token".to_string(),
3700 expires: now + 60_000,
3701 token_url: None,
3702 client_id: None,
3703 },
3704 );
3705
3706 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", None, |_| {
3707 Some("env-api-key".to_string())
3708 });
3709 let token = resolved.expect("resolved anthropic oauth token");
3710 assert_eq!(
3711 unmark_anthropic_oauth_bearer_token(&token),
3712 Some("stored-oauth-token")
3713 );
3714 }
3715
3716 #[test]
3717 fn test_resolve_api_key_expired_oauth_falls_back_to_env() {
3718 let dir = tempfile::tempdir().expect("tmpdir");
3719 let auth_path = dir.path().join("auth.json");
3720 let mut auth = AuthStorage {
3721 path: auth_path,
3722 entries: HashMap::new(),
3723 };
3724 let now = chrono::Utc::now().timestamp_millis();
3725 auth.set(
3726 "anthropic",
3727 AuthCredential::OAuth {
3728 access_token: "expired-oauth-token".to_string(),
3729 refresh_token: "refresh-token".to_string(),
3730 expires: now - 1_000,
3731 token_url: None,
3732 client_id: None,
3733 },
3734 );
3735
3736 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", None, |_| {
3737 Some("env-api-key".to_string())
3738 });
3739 assert_eq!(resolved.as_deref(), Some("env-api-key"));
3740 }
3741
3742 #[test]
3743 fn test_resolve_api_key_returns_none_when_unconfigured() {
3744 let dir = tempfile::tempdir().expect("tmpdir");
3745 let auth_path = dir.path().join("auth.json");
3746 let auth = AuthStorage {
3747 path: auth_path,
3748 entries: HashMap::new(),
3749 };
3750
3751 let resolved =
3752 auth.resolve_api_key_with_env_lookup("nonexistent-provider-for-test", None, |_| None);
3753 assert!(resolved.is_none());
3754 }
3755
3756 #[test]
3757 fn test_generate_pkce_is_base64url_no_pad() {
3758 let (verifier, challenge) = generate_pkce();
3759 assert!(!verifier.is_empty());
3760 assert!(!challenge.is_empty());
3761 assert!(!verifier.contains('+'));
3762 assert!(!verifier.contains('/'));
3763 assert!(!verifier.contains('='));
3764 assert!(!challenge.contains('+'));
3765 assert!(!challenge.contains('/'));
3766 assert!(!challenge.contains('='));
3767 assert_eq!(verifier.len(), 43);
3768 assert_eq!(challenge.len(), 43);
3769 }
3770
3771 #[test]
3772 fn test_start_anthropic_oauth_url_contains_required_params() {
3773 let info = start_anthropic_oauth().expect("start");
3774 let (base, query) = info.url.split_once('?').expect("missing query");
3775 assert_eq!(base, ANTHROPIC_OAUTH_AUTHORIZE_URL);
3776
3777 let params: std::collections::HashMap<_, _> =
3778 parse_query_pairs(query).into_iter().collect();
3779 assert_eq!(
3780 params.get("client_id").map(String::as_str),
3781 Some(ANTHROPIC_OAUTH_CLIENT_ID)
3782 );
3783 assert_eq!(
3784 params.get("response_type").map(String::as_str),
3785 Some("code")
3786 );
3787 assert_eq!(
3788 params.get("redirect_uri").map(String::as_str),
3789 Some(ANTHROPIC_OAUTH_REDIRECT_URI)
3790 );
3791 assert_eq!(
3792 params.get("scope").map(String::as_str),
3793 Some(ANTHROPIC_OAUTH_SCOPES)
3794 );
3795 assert_eq!(
3796 params.get("code_challenge_method").map(String::as_str),
3797 Some("S256")
3798 );
3799 assert_eq!(
3800 params.get("state").map(String::as_str),
3801 Some(info.verifier.as_str())
3802 );
3803 assert!(params.contains_key("code_challenge"));
3804 }
3805
3806 #[test]
3807 fn test_parse_oauth_code_input_accepts_url_and_hash_formats() {
3808 let (code, state) = parse_oauth_code_input(
3809 "https://console.anthropic.com/oauth/code/callback?code=abc&state=def",
3810 );
3811 assert_eq!(code.as_deref(), Some("abc"));
3812 assert_eq!(state.as_deref(), Some("def"));
3813
3814 let (code, state) = parse_oauth_code_input("abc#def");
3815 assert_eq!(code.as_deref(), Some("abc"));
3816 assert_eq!(state.as_deref(), Some("def"));
3817
3818 let (code, state) = parse_oauth_code_input("abc");
3819 assert_eq!(code.as_deref(), Some("abc"));
3820 assert!(state.is_none());
3821 }
3822
3823 #[test]
3824 fn test_complete_anthropic_oauth_rejects_state_mismatch() {
3825 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
3826 rt.expect("runtime").block_on(async {
3827 let err = complete_anthropic_oauth("abc#mismatch", "expected")
3828 .await
3829 .unwrap_err();
3830 assert!(err.to_string().contains("State mismatch"));
3831 });
3832 }
3833
3834 fn sample_oauth_config() -> crate::models::OAuthConfig {
3835 crate::models::OAuthConfig {
3836 auth_url: "https://auth.example.com/authorize".to_string(),
3837 token_url: "https://auth.example.com/token".to_string(),
3838 client_id: "ext-client-123".to_string(),
3839 scopes: vec!["read".to_string(), "write".to_string()],
3840 redirect_uri: Some("http://localhost:9876/callback".to_string()),
3841 }
3842 }
3843
3844 #[test]
3845 fn test_start_extension_oauth_url_contains_required_params() {
3846 let config = sample_oauth_config();
3847 let info = start_extension_oauth("my-ext-provider", &config).expect("start");
3848
3849 assert_eq!(info.provider, "my-ext-provider");
3850 assert!(!info.verifier.is_empty());
3851
3852 let (base, query) = info.url.split_once('?').expect("missing query");
3853 assert_eq!(base, "https://auth.example.com/authorize");
3854
3855 let params: std::collections::HashMap<_, _> =
3856 parse_query_pairs(query).into_iter().collect();
3857 assert_eq!(
3858 params.get("client_id").map(String::as_str),
3859 Some("ext-client-123")
3860 );
3861 assert_eq!(
3862 params.get("response_type").map(String::as_str),
3863 Some("code")
3864 );
3865 assert_eq!(
3866 params.get("redirect_uri").map(String::as_str),
3867 Some("http://localhost:9876/callback")
3868 );
3869 assert_eq!(params.get("scope").map(String::as_str), Some("read write"));
3870 assert_eq!(
3871 params.get("code_challenge_method").map(String::as_str),
3872 Some("S256")
3873 );
3874 assert_eq!(
3875 params.get("state").map(String::as_str),
3876 Some(info.verifier.as_str())
3877 );
3878 assert!(params.contains_key("code_challenge"));
3879 }
3880
3881 #[test]
3882 fn test_start_extension_oauth_no_redirect_uri() {
3883 let config = crate::models::OAuthConfig {
3884 auth_url: "https://auth.example.com/authorize".to_string(),
3885 token_url: "https://auth.example.com/token".to_string(),
3886 client_id: "ext-client-123".to_string(),
3887 scopes: vec!["read".to_string()],
3888 redirect_uri: None,
3889 };
3890 let info = start_extension_oauth("no-redirect", &config).expect("start");
3891
3892 let (_, query) = info.url.split_once('?').expect("missing query");
3893 let params: std::collections::HashMap<_, _> =
3894 parse_query_pairs(query).into_iter().collect();
3895 assert!(!params.contains_key("redirect_uri"));
3896 }
3897
3898 #[test]
3899 fn test_start_extension_oauth_empty_scopes() {
3900 let config = crate::models::OAuthConfig {
3901 auth_url: "https://auth.example.com/authorize".to_string(),
3902 token_url: "https://auth.example.com/token".to_string(),
3903 client_id: "ext-client-123".to_string(),
3904 scopes: vec![],
3905 redirect_uri: None,
3906 };
3907 let info = start_extension_oauth("empty-scopes", &config).expect("start");
3908
3909 let (_, query) = info.url.split_once('?').expect("missing query");
3910 let params: std::collections::HashMap<_, _> =
3911 parse_query_pairs(query).into_iter().collect();
3912 assert_eq!(params.get("scope").map(String::as_str), Some(""));
3914 }
3915
3916 #[test]
3917 fn test_start_extension_oauth_pkce_format() {
3918 let config = sample_oauth_config();
3919 let info = start_extension_oauth("pkce-test", &config).expect("start");
3920
3921 assert!(!info.verifier.contains('+'));
3923 assert!(!info.verifier.contains('/'));
3924 assert!(!info.verifier.contains('='));
3925 assert_eq!(info.verifier.len(), 43);
3926 }
3927
3928 #[test]
3929 fn test_complete_extension_oauth_rejects_state_mismatch() {
3930 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
3931 rt.expect("runtime").block_on(async {
3932 let config = sample_oauth_config();
3933 let err = complete_extension_oauth(&config, "abc#mismatch", "expected")
3934 .await
3935 .unwrap_err();
3936 assert!(err.to_string().contains("State mismatch"));
3937 });
3938 }
3939
3940 #[test]
3941 fn test_complete_copilot_browser_oauth_rejects_state_mismatch() {
3942 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
3943 rt.expect("runtime").block_on(async {
3944 let config = CopilotOAuthConfig::default();
3945 let err = complete_copilot_browser_oauth(&config, "abc#mismatch", "expected")
3946 .await
3947 .unwrap_err();
3948 assert!(err.to_string().contains("State mismatch"));
3949 });
3950 }
3951
3952 #[test]
3953 fn test_complete_gitlab_oauth_rejects_state_mismatch() {
3954 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
3955 rt.expect("runtime").block_on(async {
3956 let config = GitLabOAuthConfig::default();
3957 let err = complete_gitlab_oauth(&config, "abc#mismatch", "expected")
3958 .await
3959 .unwrap_err();
3960 assert!(err.to_string().contains("State mismatch"));
3961 });
3962 }
3963
3964 #[test]
3965 fn test_refresh_expired_extension_oauth_tokens_skips_anthropic() {
3966 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
3968 rt.expect("runtime").block_on(async {
3969 let dir = tempfile::tempdir().expect("tmpdir");
3970 let auth_path = dir.path().join("auth.json");
3971 let mut auth = AuthStorage {
3972 path: auth_path,
3973 entries: HashMap::new(),
3974 };
3975 let initial_access = next_token();
3977 let initial_refresh = next_token();
3978 auth.entries.insert(
3979 "anthropic".to_string(),
3980 AuthCredential::OAuth {
3981 access_token: initial_access.clone(),
3982 refresh_token: initial_refresh,
3983 expires: 0, token_url: None,
3985 client_id: None,
3986 },
3987 );
3988
3989 let client = crate::http::client::Client::new();
3990 let mut extension_configs = HashMap::new();
3991 extension_configs.insert("anthropic".to_string(), sample_oauth_config());
3992
3993 let result = auth
3995 .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
3996 .await;
3997 assert!(result.is_ok());
3998
3999 assert!(
4001 matches!(
4002 auth.entries.get("anthropic"),
4003 Some(AuthCredential::OAuth { access_token, .. })
4004 if access_token == &initial_access
4005 ),
4006 "expected OAuth credential"
4007 );
4008 });
4009 }
4010
4011 #[test]
4012 fn test_refresh_expired_extension_oauth_tokens_skips_unexpired() {
4013 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4014 rt.expect("runtime").block_on(async {
4015 let dir = tempfile::tempdir().expect("tmpdir");
4016 let auth_path = dir.path().join("auth.json");
4017 let mut auth = AuthStorage {
4018 path: auth_path,
4019 entries: HashMap::new(),
4020 };
4021 let initial_access_token = next_token();
4023 let initial_refresh_token = next_token();
4024 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
4025 auth.entries.insert(
4026 "my-ext".to_string(),
4027 AuthCredential::OAuth {
4028 access_token: initial_access_token.clone(),
4029 refresh_token: initial_refresh_token,
4030 expires: far_future,
4031 token_url: None,
4032 client_id: None,
4033 },
4034 );
4035
4036 let client = crate::http::client::Client::new();
4037 let mut extension_configs = HashMap::new();
4038 extension_configs.insert("my-ext".to_string(), sample_oauth_config());
4039
4040 let result = auth
4041 .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
4042 .await;
4043 assert!(result.is_ok());
4044
4045 assert!(
4047 matches!(
4048 auth.entries.get("my-ext"),
4049 Some(AuthCredential::OAuth { access_token, .. })
4050 if access_token == &initial_access_token
4051 ),
4052 "expected OAuth credential"
4053 );
4054 });
4055 }
4056
4057 #[test]
4058 fn test_refresh_expired_extension_oauth_tokens_skips_unknown_provider() {
4059 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4060 rt.expect("runtime").block_on(async {
4061 let dir = tempfile::tempdir().expect("tmpdir");
4062 let auth_path = dir.path().join("auth.json");
4063 let mut auth = AuthStorage {
4064 path: auth_path,
4065 entries: HashMap::new(),
4066 };
4067 let initial_access_token = next_token();
4069 let initial_refresh_token = next_token();
4070 auth.entries.insert(
4071 "unknown-ext".to_string(),
4072 AuthCredential::OAuth {
4073 access_token: initial_access_token.clone(),
4074 refresh_token: initial_refresh_token,
4075 expires: 0,
4076 token_url: None,
4077 client_id: None,
4078 },
4079 );
4080
4081 let client = crate::http::client::Client::new();
4082 let extension_configs = HashMap::new(); let result = auth
4085 .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
4086 .await;
4087 assert!(result.is_ok());
4088
4089 assert!(
4091 matches!(
4092 auth.entries.get("unknown-ext"),
4093 Some(AuthCredential::OAuth { access_token, .. })
4094 if access_token == &initial_access_token
4095 ),
4096 "expected OAuth credential"
4097 );
4098 });
4099 }
4100
4101 #[test]
4102 #[cfg(unix)]
4103 fn test_refresh_expired_extension_oauth_tokens_updates_and_persists() {
4104 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4105 rt.expect("runtime").block_on(async {
4106 let dir = tempfile::tempdir().expect("tmpdir");
4107 let auth_path = dir.path().join("auth.json");
4108 let mut auth = AuthStorage {
4109 path: auth_path.clone(),
4110 entries: HashMap::new(),
4111 };
4112 auth.entries.insert(
4113 "my-ext".to_string(),
4114 AuthCredential::OAuth {
4115 access_token: "old-access".to_string(),
4116 refresh_token: "old-refresh".to_string(),
4117 expires: 0,
4118 token_url: None,
4119 client_id: None,
4120 },
4121 );
4122
4123 let token_url = spawn_json_server(
4124 200,
4125 r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}"#,
4126 );
4127 let mut config = sample_oauth_config();
4128 config.token_url = token_url;
4129
4130 let mut extension_configs = HashMap::new();
4131 extension_configs.insert("my-ext".to_string(), config);
4132
4133 let client = crate::http::client::Client::new();
4134 auth.refresh_expired_extension_oauth_tokens(&client, &extension_configs)
4135 .await
4136 .expect("refresh");
4137
4138 let now = chrono::Utc::now().timestamp_millis();
4139 match auth.entries.get("my-ext").expect("credential updated") {
4140 AuthCredential::OAuth {
4141 access_token,
4142 refresh_token,
4143 expires,
4144 ..
4145 } => {
4146 assert_eq!(access_token, "new-access");
4147 assert_eq!(refresh_token, "new-refresh");
4148 assert!(*expires > now);
4149 }
4150 other => {
4151 unreachable!("expected oauth credential, got: {other:?}");
4152 }
4153 }
4154
4155 let reloaded = AuthStorage::load(auth_path).expect("reload");
4156 match reloaded.get("my-ext").expect("persisted credential") {
4157 AuthCredential::OAuth {
4158 access_token,
4159 refresh_token,
4160 ..
4161 } => {
4162 assert_eq!(access_token, "new-access");
4163 assert_eq!(refresh_token, "new-refresh");
4164 }
4165 other => {
4166 unreachable!("expected oauth credential, got: {other:?}");
4167 }
4168 }
4169 });
4170 }
4171
4172 #[test]
4173 #[cfg(unix)]
4174 fn test_refresh_extension_oauth_token_redacts_secret_in_error() {
4175 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4176 rt.expect("runtime").block_on(async {
4177 let refresh_secret = "secret-refresh-token-123";
4178 let leaked_access = "leaked-access-token-456";
4179 let token_url = spawn_json_server(
4180 401,
4181 &format!(
4182 r#"{{"error":"invalid_grant","echo":"{refresh_secret}","access_token":"{leaked_access}"}}"#
4183 ),
4184 );
4185
4186 let mut config = sample_oauth_config();
4187 config.token_url = token_url;
4188
4189 let client = crate::http::client::Client::new();
4190 let err = refresh_extension_oauth_token(&client, &config, refresh_secret)
4191 .await
4192 .expect_err("expected refresh failure");
4193 let err_text = err.to_string();
4194
4195 assert!(
4196 err_text.contains("[REDACTED]"),
4197 "expected redacted marker in error: {err_text}"
4198 );
4199 assert!(
4200 !err_text.contains(refresh_secret),
4201 "refresh token leaked in error: {err_text}"
4202 );
4203 assert!(
4204 !err_text.contains(leaked_access),
4205 "access token leaked in error: {err_text}"
4206 );
4207 });
4208 }
4209
4210 #[test]
4211 fn test_refresh_failure_produces_recovery_action() {
4212 let test_name = "test_refresh_failure_produces_recovery_action";
4213 log_test_event(
4214 test_name,
4215 "test_start",
4216 serde_json::json!({ "provider": "anthropic" }),
4217 );
4218
4219 let err = crate::error::Error::auth("OAuth token refresh failed: invalid_grant");
4220 let hints = err.hints();
4221 assert!(
4222 hints.hints.iter().any(|hint| hint.contains("login")),
4223 "expected auth hints to include login guidance, got {:?}",
4224 hints.hints
4225 );
4226 log_test_event(
4227 test_name,
4228 "refresh_failed",
4229 serde_json::json!({
4230 "provider": "anthropic",
4231 "error_type": "invalid_grant",
4232 "recovery": hints.hints,
4233 }),
4234 );
4235 log_test_event(
4236 test_name,
4237 "test_end",
4238 serde_json::json!({ "status": "pass" }),
4239 );
4240 }
4241
4242 #[test]
4243 fn test_refresh_failure_network_vs_auth_different_messages() {
4244 let test_name = "test_refresh_failure_network_vs_auth_different_messages";
4245 log_test_event(
4246 test_name,
4247 "test_start",
4248 serde_json::json!({ "scenario": "compare provider-network vs auth-refresh hints" }),
4249 );
4250
4251 let auth_err = crate::error::Error::auth("OAuth token refresh failed: invalid_grant");
4252 let auth_hints = auth_err.hints();
4253 let network_err = crate::error::Error::provider(
4254 "anthropic",
4255 "Network connection error: connection reset by peer",
4256 );
4257 let network_hints = network_err.hints();
4258
4259 assert!(
4260 auth_hints.hints.iter().any(|hint| hint.contains("login")),
4261 "expected auth-refresh hints to include login guidance, got {:?}",
4262 auth_hints.hints
4263 );
4264 assert!(
4265 network_hints.hints.iter().any(|hint| {
4266 let normalized = hint.to_ascii_lowercase();
4267 normalized.contains("network") || normalized.contains("connection")
4268 }),
4269 "expected network hints to mention network/connection checks, got {:?}",
4270 network_hints.hints
4271 );
4272 log_test_event(
4273 test_name,
4274 "error_classified",
4275 serde_json::json!({
4276 "auth_hints": auth_hints.hints,
4277 "network_hints": network_hints.hints,
4278 }),
4279 );
4280 log_test_event(
4281 test_name,
4282 "test_end",
4283 serde_json::json!({ "status": "pass" }),
4284 );
4285 }
4286
4287 #[test]
4288 fn test_oauth_token_storage_round_trip() {
4289 let dir = tempfile::tempdir().expect("tmpdir");
4290 let auth_path = dir.path().join("auth.json");
4291 let expected_access_token = next_token();
4292 let expected_refresh_token = next_token();
4293
4294 {
4296 let mut auth = AuthStorage {
4297 path: auth_path.clone(),
4298 entries: HashMap::new(),
4299 };
4300 auth.set(
4301 "ext-provider",
4302 AuthCredential::OAuth {
4303 access_token: expected_access_token.clone(),
4304 refresh_token: expected_refresh_token.clone(),
4305 expires: 9_999_999_999_000,
4306 token_url: None,
4307 client_id: None,
4308 },
4309 );
4310 auth.save().expect("save");
4311 }
4312
4313 let loaded = AuthStorage::load(auth_path).expect("load");
4315 let cred = loaded.get("ext-provider").expect("credential present");
4316 match cred {
4317 AuthCredential::OAuth {
4318 access_token,
4319 refresh_token,
4320 expires,
4321 ..
4322 } => {
4323 assert_eq!(access_token, &expected_access_token);
4324 assert_eq!(refresh_token, &expected_refresh_token);
4325 assert_eq!(*expires, 9_999_999_999_000);
4326 }
4327 other => {
4328 unreachable!("expected OAuth credential, got: {other:?}");
4329 }
4330 }
4331 }
4332
4333 #[test]
4334 fn test_oauth_api_key_returns_access_token_when_unexpired() {
4335 let dir = tempfile::tempdir().expect("tmpdir");
4336 let auth_path = dir.path().join("auth.json");
4337 let expected_access_token = next_token();
4338 let expected_refresh_token = next_token();
4339 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
4340 let mut auth = AuthStorage {
4341 path: auth_path,
4342 entries: HashMap::new(),
4343 };
4344 auth.set(
4345 "ext-provider",
4346 AuthCredential::OAuth {
4347 access_token: expected_access_token.clone(),
4348 refresh_token: expected_refresh_token,
4349 expires: far_future,
4350 token_url: None,
4351 client_id: None,
4352 },
4353 );
4354
4355 assert_eq!(
4356 auth.api_key("ext-provider").as_deref(),
4357 Some(expected_access_token.as_str())
4358 );
4359 }
4360
4361 #[test]
4362 fn test_oauth_api_key_returns_none_when_expired() {
4363 let dir = tempfile::tempdir().expect("tmpdir");
4364 let auth_path = dir.path().join("auth.json");
4365 let expected_access_token = next_token();
4366 let expected_refresh_token = next_token();
4367 let mut auth = AuthStorage {
4368 path: auth_path,
4369 entries: HashMap::new(),
4370 };
4371 auth.set(
4372 "ext-provider",
4373 AuthCredential::OAuth {
4374 access_token: expected_access_token,
4375 refresh_token: expected_refresh_token,
4376 expires: 0, token_url: None,
4378 client_id: None,
4379 },
4380 );
4381
4382 assert_eq!(auth.api_key("ext-provider"), None);
4383 }
4384
4385 #[test]
4386 fn test_credential_status_reports_oauth_valid_and_expired() {
4387 let dir = tempfile::tempdir().expect("tmpdir");
4388 let auth_path = dir.path().join("auth.json");
4389 let now = chrono::Utc::now().timestamp_millis();
4390
4391 let mut auth = AuthStorage {
4392 path: auth_path,
4393 entries: HashMap::new(),
4394 };
4395 auth.set(
4396 "valid-oauth",
4397 AuthCredential::OAuth {
4398 access_token: "valid-access".to_string(),
4399 refresh_token: "valid-refresh".to_string(),
4400 expires: now + 30_000,
4401 token_url: None,
4402 client_id: None,
4403 },
4404 );
4405 auth.set(
4406 "expired-oauth",
4407 AuthCredential::OAuth {
4408 access_token: "expired-access".to_string(),
4409 refresh_token: "expired-refresh".to_string(),
4410 expires: now - 30_000,
4411 token_url: None,
4412 client_id: None,
4413 },
4414 );
4415
4416 match auth.credential_status("valid-oauth") {
4417 CredentialStatus::OAuthValid { expires_in_ms } => {
4418 assert!(expires_in_ms > 0, "expires_in_ms should be positive");
4419 log_test_event(
4420 "test_provider_listing_shows_expiry",
4421 "assertion",
4422 serde_json::json!({
4423 "provider": "valid-oauth",
4424 "status": "oauth_valid",
4425 "expires_in_ms": expires_in_ms,
4426 }),
4427 );
4428 }
4429 other => panic!("expected OAuthValid, got {other:?}"),
4430 }
4431
4432 match auth.credential_status("expired-oauth") {
4433 CredentialStatus::OAuthExpired { expired_by_ms } => {
4434 assert!(expired_by_ms > 0, "expired_by_ms should be positive");
4435 }
4436 other => panic!("expected OAuthExpired, got {other:?}"),
4437 }
4438 }
4439
4440 #[test]
4441 fn test_credential_status_uses_alias_lookup() {
4442 let dir = tempfile::tempdir().expect("tmpdir");
4443 let auth_path = dir.path().join("auth.json");
4444 let mut auth = AuthStorage {
4445 path: auth_path,
4446 entries: HashMap::new(),
4447 };
4448 auth.set(
4449 "google",
4450 AuthCredential::ApiKey {
4451 key: "google-key".to_string(),
4452 },
4453 );
4454
4455 assert_eq!(auth.credential_status("gemini"), CredentialStatus::ApiKey);
4456 assert_eq!(
4457 auth.credential_status("missing-provider"),
4458 CredentialStatus::Missing
4459 );
4460 log_test_event(
4461 "test_provider_listing_shows_all_providers",
4462 "assertion",
4463 serde_json::json!({
4464 "providers_checked": ["google", "gemini", "missing-provider"],
4465 "google_status": "api_key",
4466 "missing_status": "missing",
4467 }),
4468 );
4469 log_test_event(
4470 "test_provider_listing_no_credentials",
4471 "assertion",
4472 serde_json::json!({
4473 "provider": "missing-provider",
4474 "status": "Not authenticated",
4475 }),
4476 );
4477 }
4478
4479 #[test]
4480 fn test_has_stored_credential_uses_reverse_alias_lookup() {
4481 let dir = tempfile::tempdir().expect("tmpdir");
4482 let auth_path = dir.path().join("auth.json");
4483 let mut auth = AuthStorage {
4484 path: auth_path,
4485 entries: HashMap::new(),
4486 };
4487 auth.set(
4488 "gemini",
4489 AuthCredential::ApiKey {
4490 key: "legacy-gemini-key".to_string(),
4491 },
4492 );
4493
4494 assert!(auth.has_stored_credential("google"));
4495 assert!(auth.has_stored_credential("gemini"));
4496 }
4497
4498 #[test]
4499 fn test_resolve_api_key_handles_case_insensitive_stored_provider_keys() {
4500 let dir = tempfile::tempdir().expect("tmpdir");
4501 let auth_path = dir.path().join("auth.json");
4502 let mut auth = AuthStorage {
4503 path: auth_path,
4504 entries: HashMap::new(),
4505 };
4506 auth.set(
4507 "Google",
4508 AuthCredential::ApiKey {
4509 key: "mixed-case-key".to_string(),
4510 },
4511 );
4512
4513 let resolved = auth.resolve_api_key_with_env_lookup("google", None, |_| None);
4514 assert_eq!(resolved.as_deref(), Some("mixed-case-key"));
4515 }
4516
4517 #[test]
4518 fn test_credential_status_uses_reverse_alias_lookup() {
4519 let dir = tempfile::tempdir().expect("tmpdir");
4520 let auth_path = dir.path().join("auth.json");
4521 let mut auth = AuthStorage {
4522 path: auth_path,
4523 entries: HashMap::new(),
4524 };
4525 auth.set(
4526 "gemini",
4527 AuthCredential::ApiKey {
4528 key: "legacy-gemini-key".to_string(),
4529 },
4530 );
4531
4532 assert_eq!(auth.credential_status("google"), CredentialStatus::ApiKey);
4533 }
4534
4535 #[test]
4536 fn test_remove_provider_aliases_removes_canonical_and_alias_entries() {
4537 let dir = tempfile::tempdir().expect("tmpdir");
4538 let auth_path = dir.path().join("auth.json");
4539 let mut auth = AuthStorage {
4540 path: auth_path,
4541 entries: HashMap::new(),
4542 };
4543 auth.set(
4544 "google",
4545 AuthCredential::ApiKey {
4546 key: "google-key".to_string(),
4547 },
4548 );
4549 auth.set(
4550 "gemini",
4551 AuthCredential::ApiKey {
4552 key: "gemini-key".to_string(),
4553 },
4554 );
4555
4556 assert!(auth.remove_provider_aliases("google"));
4557 assert!(!auth.has_stored_credential("google"));
4558 assert!(!auth.has_stored_credential("gemini"));
4559 }
4560
4561 #[test]
4562 fn test_auth_remove_credential() {
4563 let dir = tempfile::tempdir().expect("tmpdir");
4564 let auth_path = dir.path().join("auth.json");
4565 let mut auth = AuthStorage {
4566 path: auth_path,
4567 entries: HashMap::new(),
4568 };
4569 auth.set(
4570 "ext-provider",
4571 AuthCredential::ApiKey {
4572 key: "key-123".to_string(),
4573 },
4574 );
4575
4576 assert!(auth.get("ext-provider").is_some());
4577 assert!(auth.remove("ext-provider"));
4578 assert!(auth.get("ext-provider").is_none());
4579 assert!(!auth.remove("ext-provider")); }
4581
4582 #[test]
4583 fn test_auth_env_key_returns_none_for_extension_providers() {
4584 assert!(env_key_for_provider("my-ext-provider").is_none());
4586 assert!(env_key_for_provider("custom-llm").is_none());
4587 assert_eq!(env_key_for_provider("anthropic"), Some("ANTHROPIC_API_KEY"));
4589 assert_eq!(env_key_for_provider("openai"), Some("OPENAI_API_KEY"));
4590 }
4591
4592 #[test]
4593 fn test_extension_oauth_config_special_chars_in_scopes() {
4594 let config = crate::models::OAuthConfig {
4595 auth_url: "https://auth.example.com/authorize".to_string(),
4596 token_url: "https://auth.example.com/token".to_string(),
4597 client_id: "ext-client".to_string(),
4598 scopes: vec![
4599 "api:read".to_string(),
4600 "api:write".to_string(),
4601 "user:profile".to_string(),
4602 ],
4603 redirect_uri: None,
4604 };
4605 let info = start_extension_oauth("scoped", &config).expect("start");
4606
4607 let (_, query) = info.url.split_once('?').expect("missing query");
4608 let params: std::collections::HashMap<_, _> =
4609 parse_query_pairs(query).into_iter().collect();
4610 assert_eq!(
4611 params.get("scope").map(String::as_str),
4612 Some("api:read api:write user:profile")
4613 );
4614 }
4615
4616 #[test]
4617 fn test_extension_oauth_url_encodes_special_chars() {
4618 let config = crate::models::OAuthConfig {
4619 auth_url: "https://auth.example.com/authorize".to_string(),
4620 token_url: "https://auth.example.com/token".to_string(),
4621 client_id: "client with spaces".to_string(),
4622 scopes: vec!["scope&dangerous".to_string()],
4623 redirect_uri: Some("http://localhost:9876/call back".to_string()),
4624 };
4625 let info = start_extension_oauth("encoded", &config).expect("start");
4626
4627 assert!(info.url.contains("client%20with%20spaces"));
4629 assert!(info.url.contains("scope%26dangerous"));
4630 assert!(info.url.contains("call%20back"));
4631 }
4632
4633 #[test]
4636 fn test_auth_storage_load_valid_api_key() {
4637 let dir = tempfile::tempdir().expect("tmpdir");
4638 let auth_path = dir.path().join("auth.json");
4639 let content = r#"{"anthropic":{"type":"api_key","key":"sk-test-abc"}}"#;
4640 fs::write(&auth_path, content).expect("write");
4641
4642 let auth = AuthStorage::load(auth_path).expect("load");
4643 assert!(auth.entries.contains_key("anthropic"));
4644 match auth.get("anthropic").expect("credential") {
4645 AuthCredential::ApiKey { key } => assert_eq!(key, "sk-test-abc"),
4646 other => panic!("expected ApiKey, got: {other:?}"),
4647 }
4648 }
4649
4650 #[test]
4651 fn test_auth_storage_load_corrupted_json_returns_empty() {
4652 let dir = tempfile::tempdir().expect("tmpdir");
4653 let auth_path = dir.path().join("auth.json");
4654 fs::write(&auth_path, "not valid json {{").expect("write");
4655
4656 let auth = AuthStorage::load(auth_path).expect("load");
4657 assert!(auth.entries.is_empty());
4659 }
4660
4661 #[test]
4662 fn test_auth_storage_load_empty_file_returns_empty() {
4663 let dir = tempfile::tempdir().expect("tmpdir");
4664 let auth_path = dir.path().join("auth.json");
4665 fs::write(&auth_path, "").expect("write");
4666
4667 let auth = AuthStorage::load(auth_path).expect("load");
4668 assert!(auth.entries.is_empty());
4669 }
4670
4671 #[test]
4674 fn test_resolve_api_key_empty_override_still_wins() {
4675 let dir = tempfile::tempdir().expect("tmpdir");
4676 let auth_path = dir.path().join("auth.json");
4677 let mut auth = AuthStorage {
4678 path: auth_path,
4679 entries: HashMap::new(),
4680 };
4681 auth.set(
4682 "anthropic",
4683 AuthCredential::ApiKey {
4684 key: "stored-key".to_string(),
4685 },
4686 );
4687
4688 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", Some(""), |_| None);
4690 assert_eq!(resolved.as_deref(), Some(""));
4691 }
4692
4693 #[test]
4694 fn test_resolve_api_key_env_beats_stored() {
4695 let dir = tempfile::tempdir().expect("tmpdir");
4697 let auth_path = dir.path().join("auth.json");
4698 let mut auth = AuthStorage {
4699 path: auth_path,
4700 entries: HashMap::new(),
4701 };
4702 auth.set(
4703 "openai",
4704 AuthCredential::ApiKey {
4705 key: "stored-key".to_string(),
4706 },
4707 );
4708
4709 let resolved =
4710 auth.resolve_api_key_with_env_lookup("openai", None, |_| Some("env-key".to_string()));
4711 assert_eq!(
4712 resolved.as_deref(),
4713 Some("env-key"),
4714 "env should beat stored"
4715 );
4716 }
4717
4718 #[test]
4719 fn test_resolve_api_key_groq_env_beats_stored() {
4720 let dir = tempfile::tempdir().expect("tmpdir");
4721 let auth_path = dir.path().join("auth.json");
4722 let mut auth = AuthStorage {
4723 path: auth_path,
4724 entries: HashMap::new(),
4725 };
4726 auth.set(
4727 "groq",
4728 AuthCredential::ApiKey {
4729 key: "stored-groq-key".to_string(),
4730 },
4731 );
4732
4733 let resolved =
4734 auth.resolve_api_key_with_env_lookup("groq", None, |_| Some("env-groq-key".into()));
4735 assert_eq!(resolved.as_deref(), Some("env-groq-key"));
4736 }
4737
4738 #[test]
4739 fn test_resolve_api_key_openrouter_env_beats_stored() {
4740 let dir = tempfile::tempdir().expect("tmpdir");
4741 let auth_path = dir.path().join("auth.json");
4742 let mut auth = AuthStorage {
4743 path: auth_path,
4744 entries: HashMap::new(),
4745 };
4746 auth.set(
4747 "openrouter",
4748 AuthCredential::ApiKey {
4749 key: "stored-openrouter-key".to_string(),
4750 },
4751 );
4752
4753 let resolved = auth.resolve_api_key_with_env_lookup("openrouter", None, |var| match var {
4754 "OPENROUTER_API_KEY" => Some("env-openrouter-key".to_string()),
4755 _ => None,
4756 });
4757 assert_eq!(resolved.as_deref(), Some("env-openrouter-key"));
4758 }
4759
4760 #[test]
4761 fn test_resolve_api_key_empty_env_falls_through_to_stored() {
4762 let dir = tempfile::tempdir().expect("tmpdir");
4763 let auth_path = dir.path().join("auth.json");
4764 let mut auth = AuthStorage {
4765 path: auth_path,
4766 entries: HashMap::new(),
4767 };
4768 auth.set(
4769 "openai",
4770 AuthCredential::ApiKey {
4771 key: "stored-key".to_string(),
4772 },
4773 );
4774
4775 let resolved =
4777 auth.resolve_api_key_with_env_lookup("openai", None, |_| Some(String::new()));
4778 assert_eq!(
4779 resolved.as_deref(),
4780 Some("stored-key"),
4781 "empty env should fall through to stored"
4782 );
4783 }
4784
4785 #[test]
4786 fn test_resolve_api_key_whitespace_env_falls_through_to_stored() {
4787 let dir = tempfile::tempdir().expect("tmpdir");
4788 let auth_path = dir.path().join("auth.json");
4789 let mut auth = AuthStorage {
4790 path: auth_path,
4791 entries: HashMap::new(),
4792 };
4793 auth.set(
4794 "openai",
4795 AuthCredential::ApiKey {
4796 key: "stored-key".to_string(),
4797 },
4798 );
4799
4800 let resolved = auth.resolve_api_key_with_env_lookup("openai", None, |_| Some(" ".into()));
4801 assert_eq!(resolved.as_deref(), Some("stored-key"));
4802 }
4803
4804 #[test]
4805 fn test_resolve_api_key_anthropic_oauth_marks_for_bearer_lane() {
4806 let dir = tempfile::tempdir().expect("tmpdir");
4807 let auth_path = dir.path().join("auth.json");
4808 let mut auth = AuthStorage {
4809 path: auth_path,
4810 entries: HashMap::new(),
4811 };
4812 auth.set(
4813 "anthropic",
4814 AuthCredential::OAuth {
4815 access_token: "sk-ant-api-like-token".to_string(),
4816 refresh_token: "refresh-token".to_string(),
4817 expires: chrono::Utc::now().timestamp_millis() + 60_000,
4818 token_url: None,
4819 client_id: None,
4820 },
4821 );
4822
4823 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", None, |_| None);
4824 let token = resolved.expect("resolved anthropic oauth token");
4825 assert_eq!(
4826 unmark_anthropic_oauth_bearer_token(&token),
4827 Some("sk-ant-api-like-token")
4828 );
4829 }
4830
4831 #[test]
4832 fn test_resolve_api_key_non_anthropic_oauth_is_not_marked() {
4833 let dir = tempfile::tempdir().expect("tmpdir");
4834 let auth_path = dir.path().join("auth.json");
4835 let mut auth = AuthStorage {
4836 path: auth_path,
4837 entries: HashMap::new(),
4838 };
4839 auth.set(
4840 "openai-codex",
4841 AuthCredential::OAuth {
4842 access_token: "codex-oauth-token".to_string(),
4843 refresh_token: "refresh-token".to_string(),
4844 expires: chrono::Utc::now().timestamp_millis() + 60_000,
4845 token_url: None,
4846 client_id: None,
4847 },
4848 );
4849
4850 let resolved = auth.resolve_api_key_with_env_lookup("openai-codex", None, |_| None);
4851 assert_eq!(resolved.as_deref(), Some("codex-oauth-token"));
4852 }
4853
4854 #[test]
4855 fn test_resolve_api_key_google_uses_gemini_env_fallback() {
4856 let dir = tempfile::tempdir().expect("tmpdir");
4857 let auth_path = dir.path().join("auth.json");
4858 let mut auth = AuthStorage {
4859 path: auth_path,
4860 entries: HashMap::new(),
4861 };
4862 auth.set(
4863 "google",
4864 AuthCredential::ApiKey {
4865 key: "stored-google-key".to_string(),
4866 },
4867 );
4868
4869 let resolved = auth.resolve_api_key_with_env_lookup("google", None, |var| match var {
4870 "GOOGLE_API_KEY" => Some(String::new()),
4871 "GEMINI_API_KEY" => Some("gemini-fallback-key".to_string()),
4872 _ => None,
4873 });
4874
4875 assert_eq!(resolved.as_deref(), Some("gemini-fallback-key"));
4876 }
4877
4878 #[test]
4879 fn test_resolve_api_key_gemini_alias_reads_google_stored_key() {
4880 let dir = tempfile::tempdir().expect("tmpdir");
4881 let auth_path = dir.path().join("auth.json");
4882 let mut auth = AuthStorage {
4883 path: auth_path,
4884 entries: HashMap::new(),
4885 };
4886 auth.set(
4887 "google",
4888 AuthCredential::ApiKey {
4889 key: "stored-google-key".to_string(),
4890 },
4891 );
4892
4893 let resolved = auth.resolve_api_key_with_env_lookup("gemini", None, |_| None);
4894 assert_eq!(resolved.as_deref(), Some("stored-google-key"));
4895 }
4896
4897 #[test]
4898 fn test_resolve_api_key_google_reads_legacy_gemini_alias_stored_key() {
4899 let dir = tempfile::tempdir().expect("tmpdir");
4900 let auth_path = dir.path().join("auth.json");
4901 let mut auth = AuthStorage {
4902 path: auth_path,
4903 entries: HashMap::new(),
4904 };
4905 auth.set(
4906 "gemini",
4907 AuthCredential::ApiKey {
4908 key: "legacy-gemini-key".to_string(),
4909 },
4910 );
4911
4912 let resolved = auth.resolve_api_key_with_env_lookup("google", None, |_| None);
4913 assert_eq!(resolved.as_deref(), Some("legacy-gemini-key"));
4914 }
4915
4916 #[test]
4917 fn test_resolve_api_key_qwen_uses_qwen_env_fallback() {
4918 let dir = tempfile::tempdir().expect("tmpdir");
4919 let auth_path = dir.path().join("auth.json");
4920 let mut auth = AuthStorage {
4921 path: auth_path,
4922 entries: HashMap::new(),
4923 };
4924 auth.set(
4925 "alibaba",
4926 AuthCredential::ApiKey {
4927 key: "stored-dashscope-key".to_string(),
4928 },
4929 );
4930
4931 let resolved = auth.resolve_api_key_with_env_lookup("qwen", None, |var| match var {
4932 "DASHSCOPE_API_KEY" => Some(String::new()),
4933 "QWEN_API_KEY" => Some("qwen-fallback-key".to_string()),
4934 _ => None,
4935 });
4936
4937 assert_eq!(resolved.as_deref(), Some("qwen-fallback-key"));
4938 }
4939
4940 #[test]
4941 fn test_resolve_api_key_kimi_uses_kimi_env_fallback() {
4942 let dir = tempfile::tempdir().expect("tmpdir");
4943 let auth_path = dir.path().join("auth.json");
4944 let mut auth = AuthStorage {
4945 path: auth_path,
4946 entries: HashMap::new(),
4947 };
4948 auth.set(
4949 "moonshotai",
4950 AuthCredential::ApiKey {
4951 key: "stored-moonshot-key".to_string(),
4952 },
4953 );
4954
4955 let resolved = auth.resolve_api_key_with_env_lookup("kimi", None, |var| match var {
4956 "MOONSHOT_API_KEY" => Some(String::new()),
4957 "KIMI_API_KEY" => Some("kimi-fallback-key".to_string()),
4958 _ => None,
4959 });
4960
4961 assert_eq!(resolved.as_deref(), Some("kimi-fallback-key"));
4962 }
4963
4964 #[test]
4965 fn test_resolve_api_key_primary_env_wins_over_alias_fallback() {
4966 let dir = tempfile::tempdir().expect("tmpdir");
4967 let auth_path = dir.path().join("auth.json");
4968 let auth = AuthStorage {
4969 path: auth_path,
4970 entries: HashMap::new(),
4971 };
4972
4973 let resolved = auth.resolve_api_key_with_env_lookup("alibaba", None, |var| match var {
4974 "DASHSCOPE_API_KEY" => Some("dashscope-primary".to_string()),
4975 "QWEN_API_KEY" => Some("qwen-secondary".to_string()),
4976 _ => None,
4977 });
4978
4979 assert_eq!(resolved.as_deref(), Some("dashscope-primary"));
4980 }
4981
4982 #[test]
4985 fn test_api_key_store_and_retrieve() {
4986 let dir = tempfile::tempdir().expect("tmpdir");
4987 let auth_path = dir.path().join("auth.json");
4988 let mut auth = AuthStorage {
4989 path: auth_path,
4990 entries: HashMap::new(),
4991 };
4992
4993 auth.set(
4994 "openai",
4995 AuthCredential::ApiKey {
4996 key: "sk-openai-test".to_string(),
4997 },
4998 );
4999
5000 assert_eq!(auth.api_key("openai").as_deref(), Some("sk-openai-test"));
5001 }
5002
5003 #[test]
5004 fn test_google_api_key_overwrite_persists_latest_value() {
5005 let dir = tempfile::tempdir().expect("tmpdir");
5006 let auth_path = dir.path().join("auth.json");
5007 let mut auth = AuthStorage {
5008 path: auth_path.clone(),
5009 entries: HashMap::new(),
5010 };
5011
5012 auth.set(
5013 "google",
5014 AuthCredential::ApiKey {
5015 key: "google-key-old".to_string(),
5016 },
5017 );
5018 auth.set(
5019 "google",
5020 AuthCredential::ApiKey {
5021 key: "google-key-new".to_string(),
5022 },
5023 );
5024 auth.save().expect("save");
5025
5026 let loaded = AuthStorage::load(auth_path).expect("load");
5027 assert_eq!(loaded.api_key("google").as_deref(), Some("google-key-new"));
5028 }
5029
5030 #[test]
5031 fn test_multiple_providers_stored_and_retrieved() {
5032 let dir = tempfile::tempdir().expect("tmpdir");
5033 let auth_path = dir.path().join("auth.json");
5034 let mut auth = AuthStorage {
5035 path: auth_path.clone(),
5036 entries: HashMap::new(),
5037 };
5038
5039 auth.set(
5040 "anthropic",
5041 AuthCredential::ApiKey {
5042 key: "sk-ant".to_string(),
5043 },
5044 );
5045 auth.set(
5046 "openai",
5047 AuthCredential::ApiKey {
5048 key: "sk-oai".to_string(),
5049 },
5050 );
5051 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
5052 auth.set(
5053 "google",
5054 AuthCredential::OAuth {
5055 access_token: "goog-token".to_string(),
5056 refresh_token: "goog-refresh".to_string(),
5057 expires: far_future,
5058 token_url: None,
5059 client_id: None,
5060 },
5061 );
5062 auth.save().expect("save");
5063
5064 let loaded = AuthStorage::load(auth_path).expect("load");
5066 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("sk-ant"));
5067 assert_eq!(loaded.api_key("openai").as_deref(), Some("sk-oai"));
5068 assert_eq!(loaded.api_key("google").as_deref(), Some("goog-token"));
5069 assert_eq!(loaded.entries.len(), 3);
5070 }
5071
5072 #[test]
5073 fn test_save_creates_parent_directories() {
5074 let dir = tempfile::tempdir().expect("tmpdir");
5075 let auth_path = dir.path().join("nested").join("dirs").join("auth.json");
5076
5077 let mut auth = AuthStorage {
5078 path: auth_path.clone(),
5079 entries: HashMap::new(),
5080 };
5081 auth.set(
5082 "anthropic",
5083 AuthCredential::ApiKey {
5084 key: "nested-key".to_string(),
5085 },
5086 );
5087 auth.save().expect("save should create parents");
5088 assert!(auth_path.exists());
5089
5090 let loaded = AuthStorage::load(auth_path).expect("load");
5091 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("nested-key"));
5092 }
5093
5094 #[cfg(unix)]
5095 #[test]
5096 fn test_save_sets_600_permissions() {
5097 use std::os::unix::fs::PermissionsExt;
5098
5099 let dir = tempfile::tempdir().expect("tmpdir");
5100 let auth_path = dir.path().join("auth.json");
5101
5102 let mut auth = AuthStorage {
5103 path: auth_path.clone(),
5104 entries: HashMap::new(),
5105 };
5106 auth.set(
5107 "anthropic",
5108 AuthCredential::ApiKey {
5109 key: "secret".to_string(),
5110 },
5111 );
5112 auth.save().expect("save");
5113
5114 let metadata = fs::metadata(&auth_path).expect("metadata");
5115 let mode = metadata.permissions().mode() & 0o777;
5116 assert_eq!(mode, 0o600, "auth.json should be owner-only read/write");
5117 }
5118
5119 #[test]
5122 fn test_api_key_returns_none_for_missing_provider() {
5123 let dir = tempfile::tempdir().expect("tmpdir");
5124 let auth_path = dir.path().join("auth.json");
5125 let auth = AuthStorage {
5126 path: auth_path,
5127 entries: HashMap::new(),
5128 };
5129 assert!(auth.api_key("nonexistent").is_none());
5130 }
5131
5132 #[test]
5133 fn test_get_returns_none_for_missing_provider() {
5134 let dir = tempfile::tempdir().expect("tmpdir");
5135 let auth_path = dir.path().join("auth.json");
5136 let auth = AuthStorage {
5137 path: auth_path,
5138 entries: HashMap::new(),
5139 };
5140 assert!(auth.get("nonexistent").is_none());
5141 }
5142
5143 #[test]
5146 fn test_env_keys_all_built_in_providers() {
5147 let providers = [
5148 ("anthropic", "ANTHROPIC_API_KEY"),
5149 ("openai", "OPENAI_API_KEY"),
5150 ("google", "GOOGLE_API_KEY"),
5151 ("google-vertex", "GOOGLE_CLOUD_API_KEY"),
5152 ("amazon-bedrock", "AWS_ACCESS_KEY_ID"),
5153 ("azure-openai", "AZURE_OPENAI_API_KEY"),
5154 ("github-copilot", "GITHUB_COPILOT_API_KEY"),
5155 ("xai", "XAI_API_KEY"),
5156 ("groq", "GROQ_API_KEY"),
5157 ("deepinfra", "DEEPINFRA_API_KEY"),
5158 ("cerebras", "CEREBRAS_API_KEY"),
5159 ("openrouter", "OPENROUTER_API_KEY"),
5160 ("mistral", "MISTRAL_API_KEY"),
5161 ("cohere", "COHERE_API_KEY"),
5162 ("perplexity", "PERPLEXITY_API_KEY"),
5163 ("deepseek", "DEEPSEEK_API_KEY"),
5164 ("fireworks", "FIREWORKS_API_KEY"),
5165 ];
5166 for (provider, expected_key) in providers {
5167 let keys = env_keys_for_provider(provider);
5168 assert!(!keys.is_empty(), "expected env key for {provider}");
5169 assert_eq!(
5170 keys[0], expected_key,
5171 "wrong primary env key for {provider}"
5172 );
5173 }
5174 }
5175
5176 #[test]
5177 fn test_env_keys_togetherai_has_two_variants() {
5178 let keys = env_keys_for_provider("togetherai");
5179 assert_eq!(keys.len(), 2);
5180 assert_eq!(keys[0], "TOGETHER_API_KEY");
5181 assert_eq!(keys[1], "TOGETHER_AI_API_KEY");
5182 }
5183
5184 #[test]
5185 fn test_env_keys_google_includes_gemini_fallback() {
5186 let keys = env_keys_for_provider("google");
5187 assert_eq!(keys, &["GOOGLE_API_KEY", "GEMINI_API_KEY"]);
5188 }
5189
5190 #[test]
5191 fn test_env_keys_moonshotai_aliases() {
5192 for alias in &["moonshotai", "moonshot", "kimi"] {
5193 let keys = env_keys_for_provider(alias);
5194 assert_eq!(
5195 keys,
5196 &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
5197 "alias {alias} should map to moonshot auth fallback key chain"
5198 );
5199 }
5200 }
5201
5202 #[test]
5203 fn test_env_keys_alibaba_aliases() {
5204 for alias in &["alibaba", "dashscope", "qwen"] {
5205 let keys = env_keys_for_provider(alias);
5206 assert_eq!(
5207 keys,
5208 &["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
5209 "alias {alias} should map to dashscope auth fallback key chain"
5210 );
5211 }
5212 }
5213
5214 #[test]
5215 fn test_env_keys_native_and_gateway_aliases() {
5216 let cases: [(&str, &[&str]); 7] = [
5217 ("gemini", &["GOOGLE_API_KEY", "GEMINI_API_KEY"]),
5218 ("fireworks-ai", &["FIREWORKS_API_KEY"]),
5219 (
5220 "bedrock",
5221 &[
5222 "AWS_ACCESS_KEY_ID",
5223 "AWS_SECRET_ACCESS_KEY",
5224 "AWS_SESSION_TOKEN",
5225 "AWS_BEARER_TOKEN_BEDROCK",
5226 "AWS_PROFILE",
5227 "AWS_REGION",
5228 ] as &[&str],
5229 ),
5230 ("azure", &["AZURE_OPENAI_API_KEY"]),
5231 ("vertexai", &["GOOGLE_CLOUD_API_KEY", "VERTEX_API_KEY"]),
5232 ("copilot", &["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"]),
5233 ("fireworks", &["FIREWORKS_API_KEY"]),
5234 ];
5235
5236 for (alias, expected) in cases {
5237 let keys = env_keys_for_provider(alias);
5238 assert_eq!(keys, expected, "alias {alias} should map to {expected:?}");
5239 }
5240 }
5241
5242 #[test]
5245 fn test_percent_encode_ascii_passthrough() {
5246 assert_eq!(percent_encode_component("hello"), "hello");
5247 assert_eq!(
5248 percent_encode_component("ABCDEFxyz0189-._~"),
5249 "ABCDEFxyz0189-._~"
5250 );
5251 }
5252
5253 #[test]
5254 fn test_percent_encode_spaces_and_special() {
5255 assert_eq!(percent_encode_component("hello world"), "hello%20world");
5256 assert_eq!(percent_encode_component("a&b=c"), "a%26b%3Dc");
5257 assert_eq!(percent_encode_component("100%"), "100%25");
5258 }
5259
5260 #[test]
5261 fn test_percent_decode_passthrough() {
5262 assert_eq!(percent_decode_component("hello").as_deref(), Some("hello"));
5263 }
5264
5265 #[test]
5266 fn test_percent_decode_encoded() {
5267 assert_eq!(
5268 percent_decode_component("hello%20world").as_deref(),
5269 Some("hello world")
5270 );
5271 assert_eq!(
5272 percent_decode_component("a%26b%3Dc").as_deref(),
5273 Some("a&b=c")
5274 );
5275 }
5276
5277 #[test]
5278 fn test_percent_decode_plus_as_space() {
5279 assert_eq!(
5280 percent_decode_component("hello+world").as_deref(),
5281 Some("hello world")
5282 );
5283 }
5284
5285 #[test]
5286 fn test_percent_decode_invalid_hex_returns_none() {
5287 assert!(percent_decode_component("hello%ZZ").is_none());
5288 assert!(percent_decode_component("trailing%2").is_none());
5289 assert!(percent_decode_component("trailing%").is_none());
5290 }
5291
5292 #[test]
5293 fn test_percent_encode_decode_roundtrip() {
5294 let inputs = ["hello world", "a=1&b=2", "special: 100% /path?q=v#frag"];
5295 for input in inputs {
5296 let encoded = percent_encode_component(input);
5297 let decoded = percent_decode_component(&encoded).expect("decode");
5298 assert_eq!(decoded, input, "roundtrip failed for: {input}");
5299 }
5300 }
5301
5302 #[test]
5305 fn test_parse_query_pairs_basic() {
5306 let pairs = parse_query_pairs("code=abc&state=def");
5307 assert_eq!(pairs.len(), 2);
5308 assert_eq!(pairs[0], ("code".to_string(), "abc".to_string()));
5309 assert_eq!(pairs[1], ("state".to_string(), "def".to_string()));
5310 }
5311
5312 #[test]
5313 fn test_parse_query_pairs_empty_value() {
5314 let pairs = parse_query_pairs("key=");
5315 assert_eq!(pairs.len(), 1);
5316 assert_eq!(pairs[0], ("key".to_string(), String::new()));
5317 }
5318
5319 #[test]
5320 fn test_parse_query_pairs_no_value() {
5321 let pairs = parse_query_pairs("key");
5322 assert_eq!(pairs.len(), 1);
5323 assert_eq!(pairs[0], ("key".to_string(), String::new()));
5324 }
5325
5326 #[test]
5327 fn test_parse_query_pairs_empty_string() {
5328 let pairs = parse_query_pairs("");
5329 assert!(pairs.is_empty());
5330 }
5331
5332 #[test]
5333 fn test_parse_query_pairs_encoded_values() {
5334 let pairs = parse_query_pairs("scope=read%20write&redirect=http%3A%2F%2Fexample.com");
5335 assert_eq!(pairs.len(), 2);
5336 assert_eq!(pairs[0].1, "read write");
5337 assert_eq!(pairs[1].1, "http://example.com");
5338 }
5339
5340 #[test]
5343 fn test_build_url_basic() {
5344 let url = build_url_with_query(
5345 "https://example.com/auth",
5346 &[("key", "val"), ("foo", "bar")],
5347 );
5348 assert_eq!(url, "https://example.com/auth?key=val&foo=bar");
5349 }
5350
5351 #[test]
5352 fn test_build_url_encodes_special_chars() {
5353 let url =
5354 build_url_with_query("https://example.com", &[("q", "hello world"), ("x", "a&b")]);
5355 assert!(url.contains("q=hello%20world"));
5356 assert!(url.contains("x=a%26b"));
5357 }
5358
5359 #[test]
5360 fn test_build_url_no_params() {
5361 let url = build_url_with_query("https://example.com", &[]);
5362 assert_eq!(url, "https://example.com?");
5363 }
5364
5365 #[test]
5368 fn test_parse_oauth_code_input_empty() {
5369 let (code, state) = parse_oauth_code_input("");
5370 assert!(code.is_none());
5371 assert!(state.is_none());
5372 }
5373
5374 #[test]
5375 fn test_parse_oauth_code_input_whitespace_only() {
5376 let (code, state) = parse_oauth_code_input(" ");
5377 assert!(code.is_none());
5378 assert!(state.is_none());
5379 }
5380
5381 #[test]
5382 fn test_parse_oauth_code_input_url_strips_fragment() {
5383 let (code, state) =
5384 parse_oauth_code_input("https://example.com/callback?code=abc&state=def#fragment");
5385 assert_eq!(code.as_deref(), Some("abc"));
5386 assert_eq!(state.as_deref(), Some("def"));
5387 }
5388
5389 #[test]
5390 fn test_parse_oauth_code_input_url_code_only() {
5391 let (code, state) = parse_oauth_code_input("https://example.com/callback?code=abc");
5392 assert_eq!(code.as_deref(), Some("abc"));
5393 assert!(state.is_none());
5394 }
5395
5396 #[test]
5397 fn test_parse_oauth_code_input_hash_empty_state() {
5398 let (code, state) = parse_oauth_code_input("abc#");
5399 assert_eq!(code.as_deref(), Some("abc"));
5400 assert!(state.is_none());
5401 }
5402
5403 #[test]
5404 fn test_parse_oauth_code_input_hash_empty_code() {
5405 let (code, state) = parse_oauth_code_input("#state-only");
5406 assert!(code.is_none());
5407 assert_eq!(state.as_deref(), Some("state-only"));
5408 }
5409
5410 #[test]
5413 fn test_oauth_expires_at_ms_subtracts_safety_margin() {
5414 let now_ms = chrono::Utc::now().timestamp_millis();
5415 let expires_in = 3600; let result = oauth_expires_at_ms(expires_in);
5417
5418 let expected_approx = now_ms + 3600 * 1000 - 5 * 60 * 1000;
5420 let diff = (result - expected_approx).unsigned_abs();
5421 assert!(diff < 1000, "expected ~{expected_approx}ms, got {result}ms");
5422 }
5423
5424 #[test]
5425 fn test_oauth_expires_at_ms_zero_expires_in() {
5426 let now_ms = chrono::Utc::now().timestamp_millis();
5427 let result = oauth_expires_at_ms(0);
5428
5429 let expected_approx = now_ms - 5 * 60 * 1000;
5431 let diff = (result - expected_approx).unsigned_abs();
5432 assert!(diff < 1000, "expected ~{expected_approx}ms, got {result}ms");
5433 }
5434
5435 #[test]
5436 fn test_oauth_expires_at_ms_saturates_for_huge_positive_expires_in() {
5437 let result = oauth_expires_at_ms(i64::MAX);
5438 assert_eq!(result, i64::MAX - 5 * 60 * 1000);
5439 }
5440
5441 #[test]
5442 fn test_oauth_expires_at_ms_handles_huge_negative_expires_in() {
5443 let result = oauth_expires_at_ms(i64::MIN);
5444 assert!(result <= chrono::Utc::now().timestamp_millis());
5445 }
5446
5447 #[test]
5450 fn test_set_overwrites_existing_credential() {
5451 let dir = tempfile::tempdir().expect("tmpdir");
5452 let auth_path = dir.path().join("auth.json");
5453 let mut auth = AuthStorage {
5454 path: auth_path,
5455 entries: HashMap::new(),
5456 };
5457
5458 auth.set(
5459 "anthropic",
5460 AuthCredential::ApiKey {
5461 key: "first-key".to_string(),
5462 },
5463 );
5464 assert_eq!(auth.api_key("anthropic").as_deref(), Some("first-key"));
5465
5466 auth.set(
5467 "anthropic",
5468 AuthCredential::ApiKey {
5469 key: "second-key".to_string(),
5470 },
5471 );
5472 assert_eq!(auth.api_key("anthropic").as_deref(), Some("second-key"));
5473 assert_eq!(auth.entries.len(), 1);
5474 }
5475
5476 #[test]
5477 fn test_save_then_overwrite_persists_latest() {
5478 let dir = tempfile::tempdir().expect("tmpdir");
5479 let auth_path = dir.path().join("auth.json");
5480
5481 {
5483 let mut auth = AuthStorage {
5484 path: auth_path.clone(),
5485 entries: HashMap::new(),
5486 };
5487 auth.set(
5488 "anthropic",
5489 AuthCredential::ApiKey {
5490 key: "old-key".to_string(),
5491 },
5492 );
5493 auth.save().expect("save");
5494 }
5495
5496 {
5498 let mut auth = AuthStorage::load(auth_path.clone()).expect("load");
5499 auth.set(
5500 "anthropic",
5501 AuthCredential::ApiKey {
5502 key: "new-key".to_string(),
5503 },
5504 );
5505 auth.save().expect("save");
5506 }
5507
5508 let loaded = AuthStorage::load(auth_path).expect("load");
5510 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("new-key"));
5511 }
5512
5513 #[test]
5516 fn test_load_default_auth_works_like_load() {
5517 let dir = tempfile::tempdir().expect("tmpdir");
5518 let auth_path = dir.path().join("auth.json");
5519
5520 let mut auth = AuthStorage {
5521 path: auth_path.clone(),
5522 entries: HashMap::new(),
5523 };
5524 auth.set(
5525 "anthropic",
5526 AuthCredential::ApiKey {
5527 key: "test-key".to_string(),
5528 },
5529 );
5530 auth.save().expect("save");
5531
5532 let loaded = load_default_auth(&auth_path).expect("load_default_auth");
5533 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("test-key"));
5534 }
5535
5536 #[test]
5539 fn test_redact_known_secrets_replaces_secrets() {
5540 let text = r#"{"token":"secret123","other":"hello secret123 world"}"#;
5541 let redacted = redact_known_secrets(text, &["secret123"]);
5542 assert!(!redacted.contains("secret123"));
5543 assert!(redacted.contains("[REDACTED]"));
5544 }
5545
5546 #[test]
5547 fn test_redact_known_secrets_ignores_empty_secrets() {
5548 let text = "nothing to redact here";
5549 let redacted = redact_known_secrets(text, &["", " "]);
5550 assert_eq!(redacted, text);
5552 }
5553
5554 #[test]
5555 fn test_redact_known_secrets_multiple_secrets() {
5556 let text = "token=aaa refresh=bbb echo=aaa";
5557 let redacted = redact_known_secrets(text, &["aaa", "bbb"]);
5558 assert!(!redacted.contains("aaa"));
5559 assert!(!redacted.contains("bbb"));
5560 assert_eq!(
5561 redacted,
5562 "token=[REDACTED] refresh=[REDACTED] echo=[REDACTED]"
5563 );
5564 }
5565
5566 #[test]
5567 fn test_redact_known_secrets_no_match() {
5568 let text = "safe text with no secrets";
5569 let redacted = redact_known_secrets(text, &["not-present"]);
5570 assert_eq!(redacted, text);
5571 }
5572
5573 #[test]
5574 fn test_redact_known_secrets_redacts_oauth_json_fields_without_known_input() {
5575 let text = r#"{"access_token":"new-access","refresh_token":"new-refresh","nested":{"id_token":"new-id","safe":"ok"}}"#;
5576 let redacted = redact_known_secrets(text, &[]);
5577 assert!(redacted.contains("\"access_token\":\"[REDACTED]\""));
5578 assert!(redacted.contains("\"refresh_token\":\"[REDACTED]\""));
5579 assert!(redacted.contains("\"id_token\":\"[REDACTED]\""));
5580 assert!(redacted.contains("\"safe\":\"ok\""));
5581 assert!(!redacted.contains("new-access"));
5582 assert!(!redacted.contains("new-refresh"));
5583 assert!(!redacted.contains("new-id"));
5584 }
5585
5586 #[test]
5589 fn test_generate_pkce_unique_each_call() {
5590 let (v1, c1) = generate_pkce();
5591 let (v2, c2) = generate_pkce();
5592 assert_ne!(v1, v2, "verifiers should differ");
5593 assert_ne!(c1, c2, "challenges should differ");
5594 }
5595
5596 #[test]
5597 fn test_generate_pkce_challenge_is_sha256_of_verifier() {
5598 let (verifier, challenge) = generate_pkce();
5599 let expected_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
5600 .encode(sha2::Sha256::digest(verifier.as_bytes()));
5601 assert_eq!(challenge, expected_challenge);
5602 }
5603
5604 fn sample_copilot_config() -> CopilotOAuthConfig {
5607 CopilotOAuthConfig {
5608 client_id: "Iv1.test_copilot_id".to_string(),
5609 github_base_url: "https://github.com".to_string(),
5610 scopes: GITHUB_COPILOT_SCOPES.to_string(),
5611 }
5612 }
5613
5614 #[test]
5615 fn test_copilot_browser_oauth_requires_client_id() {
5616 let config = CopilotOAuthConfig {
5617 client_id: String::new(),
5618 ..CopilotOAuthConfig::default()
5619 };
5620 let err = start_copilot_browser_oauth(&config).unwrap_err();
5621 let msg = err.to_string();
5622 assert!(
5623 msg.contains("client_id"),
5624 "error should mention client_id: {msg}"
5625 );
5626 }
5627
5628 #[test]
5629 fn test_copilot_browser_oauth_url_contains_required_params() {
5630 let config = sample_copilot_config();
5631 let info = start_copilot_browser_oauth(&config).expect("start");
5632
5633 assert_eq!(info.provider, "github-copilot");
5634 assert!(!info.verifier.is_empty());
5635
5636 let (base, query) = info.url.split_once('?').expect("missing query");
5637 assert_eq!(base, GITHUB_OAUTH_AUTHORIZE_URL);
5638
5639 let params: std::collections::HashMap<_, _> =
5640 parse_query_pairs(query).into_iter().collect();
5641 assert_eq!(
5642 params.get("client_id").map(String::as_str),
5643 Some("Iv1.test_copilot_id")
5644 );
5645 assert_eq!(
5646 params.get("response_type").map(String::as_str),
5647 Some("code")
5648 );
5649 assert_eq!(
5650 params.get("scope").map(String::as_str),
5651 Some(GITHUB_COPILOT_SCOPES)
5652 );
5653 assert_eq!(
5654 params.get("code_challenge_method").map(String::as_str),
5655 Some("S256")
5656 );
5657 assert!(params.contains_key("code_challenge"));
5658 assert_eq!(
5659 params.get("state").map(String::as_str),
5660 Some(info.verifier.as_str())
5661 );
5662 }
5663
5664 #[test]
5665 fn test_copilot_browser_oauth_enterprise_url() {
5666 let config = CopilotOAuthConfig {
5667 client_id: "Iv1.enterprise".to_string(),
5668 github_base_url: "https://github.mycompany.com".to_string(),
5669 scopes: "read:user".to_string(),
5670 };
5671 let info = start_copilot_browser_oauth(&config).expect("start");
5672
5673 let (base, _) = info.url.split_once('?').expect("missing query");
5674 assert_eq!(base, "https://github.mycompany.com/login/oauth/authorize");
5675 }
5676
5677 #[test]
5678 fn test_copilot_browser_oauth_enterprise_trailing_slash() {
5679 let config = CopilotOAuthConfig {
5680 client_id: "Iv1.enterprise".to_string(),
5681 github_base_url: "https://github.mycompany.com/".to_string(),
5682 scopes: "read:user".to_string(),
5683 };
5684 let info = start_copilot_browser_oauth(&config).expect("start");
5685
5686 let (base, _) = info.url.split_once('?').expect("missing query");
5687 assert_eq!(base, "https://github.mycompany.com/login/oauth/authorize");
5688 }
5689
5690 #[test]
5691 fn test_copilot_browser_oauth_pkce_format() {
5692 let config = sample_copilot_config();
5693 let info = start_copilot_browser_oauth(&config).expect("start");
5694
5695 assert_eq!(info.verifier.len(), 43);
5696 assert!(!info.verifier.contains('+'));
5697 assert!(!info.verifier.contains('/'));
5698 assert!(!info.verifier.contains('='));
5699 }
5700
5701 #[test]
5702 #[cfg(unix)]
5703 fn test_copilot_browser_oauth_complete_success() {
5704 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5705 rt.expect("runtime").block_on(async {
5706 let token_url = spawn_json_server(
5707 200,
5708 r#"{"access_token":"ghu_test_access","refresh_token":"ghr_test_refresh","expires_in":28800}"#,
5709 );
5710
5711 let _config = CopilotOAuthConfig {
5713 client_id: "Iv1.test".to_string(),
5714 github_base_url: token_url.trim_end_matches("/token").replace("/token", ""),
5716 scopes: "read:user".to_string(),
5717 };
5718
5719 let cred = parse_github_token_response(
5723 r#"{"access_token":"ghu_test_access","refresh_token":"ghr_test_refresh","expires_in":28800}"#,
5724 )
5725 .expect("parse");
5726
5727 match cred {
5728 AuthCredential::OAuth {
5729 access_token,
5730 refresh_token,
5731 expires,
5732 ..
5733 } => {
5734 assert_eq!(access_token, "ghu_test_access");
5735 assert_eq!(refresh_token, "ghr_test_refresh");
5736 assert!(expires > chrono::Utc::now().timestamp_millis());
5737 }
5738 other => panic!("expected OAuth, got: {other:?}"),
5739 }
5740 });
5741 }
5742
5743 #[test]
5744 fn test_parse_github_token_no_refresh_token() {
5745 let cred =
5746 parse_github_token_response(r#"{"access_token":"ghu_test","token_type":"bearer"}"#)
5747 .expect("parse");
5748
5749 match cred {
5750 AuthCredential::OAuth {
5751 access_token,
5752 refresh_token,
5753 ..
5754 } => {
5755 assert_eq!(access_token, "ghu_test");
5756 assert!(refresh_token.is_empty(), "should default to empty");
5757 }
5758 other => panic!("expected OAuth, got: {other:?}"),
5759 }
5760 }
5761
5762 #[test]
5763 fn test_parse_github_token_no_expiry_uses_far_future() {
5764 let cred = parse_github_token_response(
5765 r#"{"access_token":"ghu_test","refresh_token":"ghr_test"}"#,
5766 )
5767 .expect("parse");
5768
5769 match cred {
5770 AuthCredential::OAuth { expires, .. } => {
5771 let now = chrono::Utc::now().timestamp_millis();
5772 let one_year_ms = 365 * 24 * 3600 * 1000_i64;
5773 assert!(
5775 expires > now + one_year_ms - 10 * 60 * 1000,
5776 "expected far-future expiry"
5777 );
5778 }
5779 other => panic!("expected OAuth, got: {other:?}"),
5780 }
5781 }
5782
5783 #[test]
5784 fn test_parse_github_token_missing_access_token_fails() {
5785 let err = parse_github_token_response(r#"{"refresh_token":"ghr_test"}"#).unwrap_err();
5786 assert!(err.to_string().contains("access_token"));
5787 }
5788
5789 #[test]
5790 fn test_copilot_diagnostic_includes_troubleshooting() {
5791 let msg = copilot_diagnostic("Token exchange failed", "bad request");
5792 assert!(msg.contains("Token exchange failed"));
5793 assert!(msg.contains("Troubleshooting"));
5794 assert!(msg.contains("client_id"));
5795 assert!(msg.contains("Copilot subscription"));
5796 assert!(msg.contains("Enterprise"));
5797 }
5798
5799 #[test]
5802 fn test_device_code_response_deserialize() {
5803 let json = r#"{
5804 "device_code": "dc_test",
5805 "user_code": "ABCD-1234",
5806 "verification_uri": "https://github.com/login/device",
5807 "expires_in": 900,
5808 "interval": 5
5809 }"#;
5810 let resp: DeviceCodeResponse = serde_json::from_str(json).expect("parse");
5811 assert_eq!(resp.device_code, "dc_test");
5812 assert_eq!(resp.user_code, "ABCD-1234");
5813 assert_eq!(resp.verification_uri, "https://github.com/login/device");
5814 assert_eq!(resp.expires_in, 900);
5815 assert_eq!(resp.interval, 5);
5816 assert!(resp.verification_uri_complete.is_none());
5817 }
5818
5819 #[test]
5820 fn test_device_code_response_default_interval() {
5821 let json = r#"{
5822 "device_code": "dc",
5823 "user_code": "CODE",
5824 "verification_uri": "https://github.com/login/device",
5825 "expires_in": 600
5826 }"#;
5827 let resp: DeviceCodeResponse = serde_json::from_str(json).expect("parse");
5828 assert_eq!(resp.interval, 5, "default interval should be 5 seconds");
5829 }
5830
5831 #[test]
5832 fn test_device_code_response_with_complete_uri() {
5833 let json = r#"{
5834 "device_code": "dc",
5835 "user_code": "CODE",
5836 "verification_uri": "https://github.com/login/device",
5837 "verification_uri_complete": "https://github.com/login/device?user_code=CODE",
5838 "expires_in": 600,
5839 "interval": 10
5840 }"#;
5841 let resp: DeviceCodeResponse = serde_json::from_str(json).expect("parse");
5842 assert_eq!(
5843 resp.verification_uri_complete.as_deref(),
5844 Some("https://github.com/login/device?user_code=CODE")
5845 );
5846 }
5847
5848 #[test]
5849 fn test_copilot_device_flow_requires_client_id() {
5850 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5851 rt.expect("runtime").block_on(async {
5852 let config = CopilotOAuthConfig {
5853 client_id: String::new(),
5854 ..CopilotOAuthConfig::default()
5855 };
5856 let err = start_copilot_device_flow(&config).await.unwrap_err();
5857 assert!(err.to_string().contains("client_id"));
5858 });
5859 }
5860
5861 #[test]
5862 fn test_kimi_oauth_host_env_lookup_prefers_primary_host() {
5863 let host = kimi_code_oauth_host_with_env_lookup(|key| match key {
5864 "KIMI_CODE_OAUTH_HOST" => Some("https://primary.kimi.test".to_string()),
5865 "KIMI_OAUTH_HOST" => Some("https://fallback.kimi.test".to_string()),
5866 _ => None,
5867 });
5868 assert_eq!(host, "https://primary.kimi.test");
5869 }
5870
5871 #[test]
5872 fn test_kimi_share_dir_env_lookup_prefers_kimi_share_dir() {
5873 let share_dir = kimi_share_dir_with_env_lookup(|key| match key {
5874 "KIMI_SHARE_DIR" => Some("/tmp/custom-kimi-share".to_string()),
5875 "HOME" => Some("/tmp/home".to_string()),
5876 _ => None,
5877 });
5878 assert_eq!(
5879 share_dir,
5880 Some(PathBuf::from("/tmp/custom-kimi-share")),
5881 "KIMI_SHARE_DIR should override HOME-based default"
5882 );
5883 }
5884
5885 #[test]
5886 fn test_kimi_share_dir_env_lookup_falls_back_to_home() {
5887 let share_dir = kimi_share_dir_with_env_lookup(|key| match key {
5888 "KIMI_SHARE_DIR" => Some(" ".to_string()),
5889 "HOME" => Some("/tmp/home".to_string()),
5890 _ => None,
5891 });
5892 assert_eq!(share_dir, Some(PathBuf::from("/tmp/home/.kimi")));
5893 }
5894
5895 #[test]
5896 fn test_home_dir_env_lookup_falls_back_to_userprofile() {
5897 let home = home_dir_with_env_lookup(|key| match key {
5898 "HOME" => Some(" ".to_string()),
5899 "USERPROFILE" => Some("C:\\Users\\tester".to_string()),
5900 _ => None,
5901 });
5902 assert_eq!(home, Some(PathBuf::from("C:\\Users\\tester")));
5903 }
5904
5905 #[test]
5906 fn test_home_dir_env_lookup_falls_back_to_homedrive_homepath() {
5907 let home = home_dir_with_env_lookup(|key| match key {
5908 "HOMEDRIVE" => Some("C:".to_string()),
5909 "HOMEPATH" => Some("\\Users\\tester".to_string()),
5910 _ => None,
5911 });
5912 assert_eq!(home, Some(PathBuf::from("C:\\Users\\tester")));
5913 }
5914
5915 #[test]
5916 fn test_home_dir_env_lookup_homedrive_homepath_without_root_separator() {
5917 let home = home_dir_with_env_lookup(|key| match key {
5918 "HOMEDRIVE" => Some("C:".to_string()),
5919 "HOMEPATH" => Some("Users\\tester".to_string()),
5920 _ => None,
5921 });
5922 assert_eq!(home, Some(PathBuf::from("C:/Users\\tester")));
5923 }
5924
5925 #[test]
5926 fn test_read_external_kimi_code_access_token_from_share_dir_reads_unexpired_token() {
5927 let dir = tempfile::tempdir().expect("tmpdir");
5928 let share_dir = dir.path();
5929 let credentials_dir = share_dir.join("credentials");
5930 std::fs::create_dir_all(&credentials_dir).expect("create credentials dir");
5931 let path = credentials_dir.join("kimi-code.json");
5932 let expires_at = chrono::Utc::now().timestamp() + 3600;
5933 std::fs::write(
5934 &path,
5935 format!(r#"{{"access_token":" kimi-token ","expires_at":{expires_at}}}"#),
5936 )
5937 .expect("write token file");
5938
5939 let token = read_external_kimi_code_access_token_from_share_dir(share_dir);
5940 assert_eq!(token.as_deref(), Some("kimi-token"));
5941 }
5942
5943 #[test]
5944 fn test_read_external_kimi_code_access_token_from_share_dir_ignores_expired_token() {
5945 let dir = tempfile::tempdir().expect("tmpdir");
5946 let share_dir = dir.path();
5947 let credentials_dir = share_dir.join("credentials");
5948 std::fs::create_dir_all(&credentials_dir).expect("create credentials dir");
5949 let path = credentials_dir.join("kimi-code.json");
5950 let expires_at = chrono::Utc::now().timestamp() - 5;
5951 std::fs::write(
5952 &path,
5953 format!(r#"{{"access_token":"kimi-token","expires_at":{expires_at}}}"#),
5954 )
5955 .expect("write token file");
5956
5957 let token = read_external_kimi_code_access_token_from_share_dir(share_dir);
5958 assert!(token.is_none(), "expired Kimi token should be ignored");
5959 }
5960
5961 #[test]
5962 fn test_start_kimi_code_device_flow_parses_response() {
5963 let host = spawn_oauth_host_server(
5964 200,
5965 r#"{
5966 "device_code": "dc_test",
5967 "user_code": "ABCD-1234",
5968 "verification_uri": "https://auth.kimi.com/device",
5969 "verification_uri_complete": "https://auth.kimi.com/device?user_code=ABCD-1234",
5970 "expires_in": 900,
5971 "interval": 5
5972 }"#,
5973 );
5974 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5975 rt.expect("runtime").block_on(async {
5976 let client = crate::http::client::Client::new();
5977 let response = start_kimi_code_device_flow_with_client(&client, &host)
5978 .await
5979 .expect("start kimi device flow");
5980 assert_eq!(response.device_code, "dc_test");
5981 assert_eq!(response.user_code, "ABCD-1234");
5982 assert_eq!(response.expires_in, 900);
5983 assert_eq!(response.interval, 5);
5984 assert_eq!(
5985 response.verification_uri_complete.as_deref(),
5986 Some("https://auth.kimi.com/device?user_code=ABCD-1234")
5987 );
5988 });
5989 }
5990
5991 #[test]
5992 fn test_poll_kimi_code_device_flow_success_returns_oauth_credential() {
5993 let host = spawn_oauth_host_server(
5994 200,
5995 r#"{"access_token":"kimi-at","refresh_token":"kimi-rt","expires_in":3600}"#,
5996 );
5997 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5998 rt.expect("runtime").block_on(async {
5999 let client = crate::http::client::Client::new();
6000 let result =
6001 poll_kimi_code_device_flow_with_client(&client, &host, "device-code").await;
6002 match result {
6003 DeviceFlowPollResult::Success(AuthCredential::OAuth {
6004 access_token,
6005 refresh_token,
6006 token_url,
6007 client_id,
6008 ..
6009 }) => {
6010 let expected_token_url = format!("{host}{KIMI_CODE_TOKEN_PATH}");
6011 assert_eq!(access_token, "kimi-at");
6012 assert_eq!(refresh_token, "kimi-rt");
6013 assert_eq!(token_url.as_deref(), Some(expected_token_url.as_str()));
6014 assert_eq!(client_id.as_deref(), Some(KIMI_CODE_OAUTH_CLIENT_ID));
6015 }
6016 other => panic!("expected success, got {other:?}"),
6017 }
6018 });
6019 }
6020
6021 #[test]
6022 fn test_poll_kimi_code_device_flow_pending_state() {
6023 let host = spawn_oauth_host_server(
6024 400,
6025 r#"{"error":"authorization_pending","error_description":"wait"}"#,
6026 );
6027 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6028 rt.expect("runtime").block_on(async {
6029 let client = crate::http::client::Client::new();
6030 let result =
6031 poll_kimi_code_device_flow_with_client(&client, &host, "device-code").await;
6032 assert!(matches!(result, DeviceFlowPollResult::Pending));
6033 });
6034 }
6035
6036 fn sample_gitlab_config() -> GitLabOAuthConfig {
6039 GitLabOAuthConfig {
6040 client_id: "gl_test_app_id".to_string(),
6041 base_url: GITLAB_DEFAULT_BASE_URL.to_string(),
6042 scopes: GITLAB_DEFAULT_SCOPES.to_string(),
6043 redirect_uri: Some("http://localhost:8765/callback".to_string()),
6044 }
6045 }
6046
6047 #[test]
6048 fn test_gitlab_oauth_requires_client_id() {
6049 let config = GitLabOAuthConfig {
6050 client_id: String::new(),
6051 ..GitLabOAuthConfig::default()
6052 };
6053 let err = start_gitlab_oauth(&config).unwrap_err();
6054 let msg = err.to_string();
6055 assert!(
6056 msg.contains("client_id"),
6057 "error should mention client_id: {msg}"
6058 );
6059 assert!(msg.contains("Settings"), "should mention GitLab settings");
6060 }
6061
6062 #[test]
6063 fn test_gitlab_oauth_url_contains_required_params() {
6064 let config = sample_gitlab_config();
6065 let info = start_gitlab_oauth(&config).expect("start");
6066
6067 assert_eq!(info.provider, "gitlab");
6068 assert!(!info.verifier.is_empty());
6069
6070 let (base, query) = info.url.split_once('?').expect("missing query");
6071 assert_eq!(base, "https://gitlab.com/oauth/authorize");
6072
6073 let params: std::collections::HashMap<_, _> =
6074 parse_query_pairs(query).into_iter().collect();
6075 assert_eq!(
6076 params.get("client_id").map(String::as_str),
6077 Some("gl_test_app_id")
6078 );
6079 assert_eq!(
6080 params.get("response_type").map(String::as_str),
6081 Some("code")
6082 );
6083 assert_eq!(
6084 params.get("scope").map(String::as_str),
6085 Some(GITLAB_DEFAULT_SCOPES)
6086 );
6087 assert_eq!(
6088 params.get("redirect_uri").map(String::as_str),
6089 Some("http://localhost:8765/callback")
6090 );
6091 assert_eq!(
6092 params.get("code_challenge_method").map(String::as_str),
6093 Some("S256")
6094 );
6095 assert!(params.contains_key("code_challenge"));
6096 assert_eq!(
6097 params.get("state").map(String::as_str),
6098 Some(info.verifier.as_str())
6099 );
6100 }
6101
6102 #[test]
6103 fn test_gitlab_oauth_self_hosted_url() {
6104 let config = GitLabOAuthConfig {
6105 client_id: "gl_self_hosted".to_string(),
6106 base_url: "https://gitlab.mycompany.com".to_string(),
6107 scopes: "api".to_string(),
6108 redirect_uri: None,
6109 };
6110 let info = start_gitlab_oauth(&config).expect("start");
6111
6112 let (base, _) = info.url.split_once('?').expect("missing query");
6113 assert_eq!(base, "https://gitlab.mycompany.com/oauth/authorize");
6114 assert!(
6115 info.instructions
6116 .as_deref()
6117 .unwrap_or("")
6118 .contains("gitlab.mycompany.com"),
6119 "instructions should mention the base URL"
6120 );
6121 }
6122
6123 #[test]
6124 fn test_gitlab_oauth_self_hosted_trailing_slash() {
6125 let config = GitLabOAuthConfig {
6126 client_id: "gl_self_hosted".to_string(),
6127 base_url: "https://gitlab.mycompany.com/".to_string(),
6128 scopes: "api".to_string(),
6129 redirect_uri: None,
6130 };
6131 let info = start_gitlab_oauth(&config).expect("start");
6132
6133 let (base, _) = info.url.split_once('?').expect("missing query");
6134 assert_eq!(base, "https://gitlab.mycompany.com/oauth/authorize");
6135 }
6136
6137 #[test]
6138 fn test_gitlab_oauth_no_redirect_uri() {
6139 let config = GitLabOAuthConfig {
6140 client_id: "gl_no_redirect".to_string(),
6141 base_url: GITLAB_DEFAULT_BASE_URL.to_string(),
6142 scopes: "api".to_string(),
6143 redirect_uri: None,
6144 };
6145 let info = start_gitlab_oauth(&config).expect("start");
6146
6147 let (_, query) = info.url.split_once('?').expect("missing query");
6148 let params: std::collections::HashMap<_, _> =
6149 parse_query_pairs(query).into_iter().collect();
6150 assert!(
6151 !params.contains_key("redirect_uri"),
6152 "redirect_uri should be absent"
6153 );
6154 }
6155
6156 #[test]
6157 fn test_gitlab_oauth_pkce_format() {
6158 let config = sample_gitlab_config();
6159 let info = start_gitlab_oauth(&config).expect("start");
6160
6161 assert_eq!(info.verifier.len(), 43);
6162 assert!(!info.verifier.contains('+'));
6163 assert!(!info.verifier.contains('/'));
6164 assert!(!info.verifier.contains('='));
6165 }
6166
6167 #[test]
6168 #[cfg(unix)]
6169 fn test_gitlab_oauth_complete_success() {
6170 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6171 rt.expect("runtime").block_on(async {
6172 let token_url = spawn_json_server(
6173 200,
6174 r#"{"access_token":"glpat-test_access","refresh_token":"glrt-test_refresh","expires_in":7200,"token_type":"bearer"}"#,
6175 );
6176
6177 let response: OAuthTokenResponse = serde_json::from_str(
6179 r#"{"access_token":"glpat-test_access","refresh_token":"glrt-test_refresh","expires_in":7200}"#,
6180 )
6181 .expect("parse");
6182
6183 let cred = AuthCredential::OAuth {
6184 access_token: response.access_token,
6185 refresh_token: response.refresh_token,
6186 expires: oauth_expires_at_ms(response.expires_in),
6187 token_url: None,
6188 client_id: None,
6189 };
6190
6191 match cred {
6192 AuthCredential::OAuth {
6193 access_token,
6194 refresh_token,
6195 expires,
6196 ..
6197 } => {
6198 assert_eq!(access_token, "glpat-test_access");
6199 assert_eq!(refresh_token, "glrt-test_refresh");
6200 assert!(expires > chrono::Utc::now().timestamp_millis());
6201 }
6202 other => panic!("expected OAuth, got: {other:?}"),
6203 }
6204
6205 let _ = token_url;
6207 });
6208 }
6209
6210 #[test]
6211 fn test_gitlab_diagnostic_includes_troubleshooting() {
6212 let msg = gitlab_diagnostic("https://gitlab.com", "Token exchange failed", "bad request");
6213 assert!(msg.contains("Token exchange failed"));
6214 assert!(msg.contains("Troubleshooting"));
6215 assert!(msg.contains("client_id"));
6216 assert!(msg.contains("Settings > Applications"));
6217 assert!(msg.contains("https://gitlab.com"));
6218 }
6219
6220 #[test]
6221 fn test_gitlab_diagnostic_self_hosted_url_in_message() {
6222 let msg = gitlab_diagnostic("https://gitlab.mycompany.com", "Auth failed", "HTTP 401");
6223 assert!(
6224 msg.contains("gitlab.mycompany.com"),
6225 "should reference the self-hosted URL"
6226 );
6227 }
6228
6229 #[test]
6232 fn test_env_keys_gitlab_provider() {
6233 let keys = env_keys_for_provider("gitlab");
6234 assert_eq!(keys, &["GITLAB_TOKEN", "GITLAB_API_KEY"]);
6235 }
6236
6237 #[test]
6238 fn test_env_keys_gitlab_duo_alias() {
6239 let keys = env_keys_for_provider("gitlab-duo");
6240 assert_eq!(keys, &["GITLAB_TOKEN", "GITLAB_API_KEY"]);
6241 }
6242
6243 #[test]
6244 fn test_env_keys_copilot_includes_github_token() {
6245 let keys = env_keys_for_provider("github-copilot");
6246 assert_eq!(keys, &["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"]);
6247 }
6248
6249 #[test]
6252 fn test_copilot_config_default() {
6253 let config = CopilotOAuthConfig::default();
6254 assert!(config.client_id.is_empty());
6255 assert_eq!(config.github_base_url, "https://github.com");
6256 assert_eq!(config.scopes, GITHUB_COPILOT_SCOPES);
6257 }
6258
6259 #[test]
6260 fn test_gitlab_config_default() {
6261 let config = GitLabOAuthConfig::default();
6262 assert!(config.client_id.is_empty());
6263 assert_eq!(config.base_url, GITLAB_DEFAULT_BASE_URL);
6264 assert_eq!(config.scopes, GITLAB_DEFAULT_SCOPES);
6265 assert!(config.redirect_uri.is_none());
6266 }
6267
6268 #[test]
6271 fn test_trim_trailing_slash_noop() {
6272 assert_eq!(
6273 trim_trailing_slash("https://github.com"),
6274 "https://github.com"
6275 );
6276 }
6277
6278 #[test]
6279 fn test_trim_trailing_slash_single() {
6280 assert_eq!(
6281 trim_trailing_slash("https://github.com/"),
6282 "https://github.com"
6283 );
6284 }
6285
6286 #[test]
6287 fn test_trim_trailing_slash_multiple() {
6288 assert_eq!(
6289 trim_trailing_slash("https://github.com///"),
6290 "https://github.com"
6291 );
6292 }
6293
6294 #[test]
6297 fn test_aws_credentials_round_trip() {
6298 let cred = AuthCredential::AwsCredentials {
6299 access_key_id: "AKIAEXAMPLE".to_string(),
6300 secret_access_key: "wJalrXUtnFEMI/SECRET".to_string(),
6301 session_token: Some("FwoGZX...session".to_string()),
6302 region: Some("us-west-2".to_string()),
6303 };
6304 let json = serde_json::to_string(&cred).expect("serialize");
6305 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
6306 match parsed {
6307 AuthCredential::AwsCredentials {
6308 access_key_id,
6309 secret_access_key,
6310 session_token,
6311 region,
6312 } => {
6313 assert_eq!(access_key_id, "AKIAEXAMPLE");
6314 assert_eq!(secret_access_key, "wJalrXUtnFEMI/SECRET");
6315 assert_eq!(session_token.as_deref(), Some("FwoGZX...session"));
6316 assert_eq!(region.as_deref(), Some("us-west-2"));
6317 }
6318 other => panic!("expected AwsCredentials, got: {other:?}"),
6319 }
6320 }
6321
6322 #[test]
6323 fn test_aws_credentials_without_optional_fields() {
6324 let json =
6325 r#"{"type":"aws_credentials","access_key_id":"AKIA","secret_access_key":"secret"}"#;
6326 let cred: AuthCredential = serde_json::from_str(json).expect("deserialize");
6327 match cred {
6328 AuthCredential::AwsCredentials {
6329 session_token,
6330 region,
6331 ..
6332 } => {
6333 assert!(session_token.is_none());
6334 assert!(region.is_none());
6335 }
6336 other => panic!("expected AwsCredentials, got: {other:?}"),
6337 }
6338 }
6339
6340 #[test]
6341 fn test_bearer_token_round_trip() {
6342 let cred = AuthCredential::BearerToken {
6343 token: "my-gateway-token-123".to_string(),
6344 };
6345 let json = serde_json::to_string(&cred).expect("serialize");
6346 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
6347 match parsed {
6348 AuthCredential::BearerToken { token } => {
6349 assert_eq!(token, "my-gateway-token-123");
6350 }
6351 other => panic!("expected BearerToken, got: {other:?}"),
6352 }
6353 }
6354
6355 #[test]
6356 fn test_service_key_round_trip() {
6357 let cred = AuthCredential::ServiceKey {
6358 client_id: Some("sap-client-id".to_string()),
6359 client_secret: Some("sap-secret".to_string()),
6360 token_url: Some("https://auth.sap.com/oauth/token".to_string()),
6361 service_url: Some("https://api.ai.sap.com".to_string()),
6362 };
6363 let json = serde_json::to_string(&cred).expect("serialize");
6364 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
6365 match parsed {
6366 AuthCredential::ServiceKey {
6367 client_id,
6368 client_secret,
6369 token_url,
6370 service_url,
6371 } => {
6372 assert_eq!(client_id.as_deref(), Some("sap-client-id"));
6373 assert_eq!(client_secret.as_deref(), Some("sap-secret"));
6374 assert_eq!(
6375 token_url.as_deref(),
6376 Some("https://auth.sap.com/oauth/token")
6377 );
6378 assert_eq!(service_url.as_deref(), Some("https://api.ai.sap.com"));
6379 }
6380 other => panic!("expected ServiceKey, got: {other:?}"),
6381 }
6382 }
6383
6384 #[test]
6385 fn test_service_key_without_optional_fields() {
6386 let json = r#"{"type":"service_key"}"#;
6387 let cred: AuthCredential = serde_json::from_str(json).expect("deserialize");
6388 match cred {
6389 AuthCredential::ServiceKey {
6390 client_id,
6391 client_secret,
6392 token_url,
6393 service_url,
6394 } => {
6395 assert!(client_id.is_none());
6396 assert!(client_secret.is_none());
6397 assert!(token_url.is_none());
6398 assert!(service_url.is_none());
6399 }
6400 other => panic!("expected ServiceKey, got: {other:?}"),
6401 }
6402 }
6403
6404 #[test]
6407 fn test_api_key_returns_bearer_token() {
6408 let dir = tempfile::tempdir().expect("tmpdir");
6409 let mut auth = AuthStorage {
6410 path: dir.path().join("auth.json"),
6411 entries: HashMap::new(),
6412 };
6413 auth.set(
6414 "my-gateway",
6415 AuthCredential::BearerToken {
6416 token: "gw-tok-123".to_string(),
6417 },
6418 );
6419 assert_eq!(auth.api_key("my-gateway").as_deref(), Some("gw-tok-123"));
6420 }
6421
6422 #[test]
6423 fn test_api_key_returns_aws_access_key_id() {
6424 let dir = tempfile::tempdir().expect("tmpdir");
6425 let mut auth = AuthStorage {
6426 path: dir.path().join("auth.json"),
6427 entries: HashMap::new(),
6428 };
6429 auth.set(
6430 "amazon-bedrock",
6431 AuthCredential::AwsCredentials {
6432 access_key_id: "AKIAEXAMPLE".to_string(),
6433 secret_access_key: "secret".to_string(),
6434 session_token: None,
6435 region: None,
6436 },
6437 );
6438 assert_eq!(
6439 auth.api_key("amazon-bedrock").as_deref(),
6440 Some("AKIAEXAMPLE")
6441 );
6442 }
6443
6444 #[test]
6445 fn test_api_key_returns_none_for_service_key() {
6446 let dir = tempfile::tempdir().expect("tmpdir");
6447 let mut auth = AuthStorage {
6448 path: dir.path().join("auth.json"),
6449 entries: HashMap::new(),
6450 };
6451 auth.set(
6452 "sap-ai-core",
6453 AuthCredential::ServiceKey {
6454 client_id: Some("id".to_string()),
6455 client_secret: Some("secret".to_string()),
6456 token_url: Some("https://auth.example.com".to_string()),
6457 service_url: Some("https://api.example.com".to_string()),
6458 },
6459 );
6460 assert!(auth.api_key("sap-ai-core").is_none());
6461 }
6462
6463 fn empty_auth() -> AuthStorage {
6466 let dir = tempfile::tempdir().expect("tmpdir");
6467 AuthStorage {
6468 path: dir.path().join("auth.json"),
6469 entries: HashMap::new(),
6470 }
6471 }
6472
6473 #[test]
6474 fn test_aws_bearer_token_env_wins() {
6475 let auth = empty_auth();
6476 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6477 "AWS_BEARER_TOKEN_BEDROCK" => Some("bearer-tok-env".to_string()),
6478 "AWS_REGION" => Some("eu-west-1".to_string()),
6479 "AWS_ACCESS_KEY_ID" => Some("AKIA_SHOULD_NOT_WIN".to_string()),
6480 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
6481 _ => None,
6482 });
6483 assert_eq!(
6484 result,
6485 Some(AwsResolvedCredentials::Bearer {
6486 token: "bearer-tok-env".to_string(),
6487 region: "eu-west-1".to_string(),
6488 })
6489 );
6490 }
6491
6492 #[test]
6493 fn test_aws_env_sigv4_credentials() {
6494 let auth = empty_auth();
6495 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6496 "AWS_ACCESS_KEY_ID" => Some("AKIATEST".to_string()),
6497 "AWS_SECRET_ACCESS_KEY" => Some("secretTEST".to_string()),
6498 "AWS_SESSION_TOKEN" => Some("session123".to_string()),
6499 "AWS_REGION" => Some("ap-southeast-1".to_string()),
6500 _ => None,
6501 });
6502 assert_eq!(
6503 result,
6504 Some(AwsResolvedCredentials::Sigv4 {
6505 access_key_id: "AKIATEST".to_string(),
6506 secret_access_key: "secretTEST".to_string(),
6507 session_token: Some("session123".to_string()),
6508 region: "ap-southeast-1".to_string(),
6509 })
6510 );
6511 }
6512
6513 #[test]
6514 fn test_aws_env_sigv4_without_session_token() {
6515 let auth = empty_auth();
6516 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6517 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
6518 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
6519 _ => None,
6520 });
6521 assert_eq!(
6522 result,
6523 Some(AwsResolvedCredentials::Sigv4 {
6524 access_key_id: "AKIA".to_string(),
6525 secret_access_key: "secret".to_string(),
6526 session_token: None,
6527 region: "us-east-1".to_string(),
6528 })
6529 );
6530 }
6531
6532 #[test]
6533 fn test_aws_default_region_fallback() {
6534 let auth = empty_auth();
6535 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6536 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
6537 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
6538 "AWS_DEFAULT_REGION" => Some("ca-central-1".to_string()),
6539 _ => None,
6540 });
6541 match result {
6542 Some(AwsResolvedCredentials::Sigv4 { region, .. }) => {
6543 assert_eq!(region, "ca-central-1");
6544 }
6545 other => panic!("expected Sigv4, got: {other:?}"),
6546 }
6547 }
6548
6549 #[test]
6550 fn test_aws_stored_credentials_fallback() {
6551 let dir = tempfile::tempdir().expect("tmpdir");
6552 let mut auth = AuthStorage {
6553 path: dir.path().join("auth.json"),
6554 entries: HashMap::new(),
6555 };
6556 auth.set(
6557 "amazon-bedrock",
6558 AuthCredential::AwsCredentials {
6559 access_key_id: "AKIA_STORED".to_string(),
6560 secret_access_key: "secret_stored".to_string(),
6561 session_token: None,
6562 region: Some("us-west-2".to_string()),
6563 },
6564 );
6565 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
6566 assert_eq!(
6567 result,
6568 Some(AwsResolvedCredentials::Sigv4 {
6569 access_key_id: "AKIA_STORED".to_string(),
6570 secret_access_key: "secret_stored".to_string(),
6571 session_token: None,
6572 region: "us-west-2".to_string(),
6573 })
6574 );
6575 }
6576
6577 #[test]
6578 fn test_aws_stored_bearer_fallback() {
6579 let dir = tempfile::tempdir().expect("tmpdir");
6580 let mut auth = AuthStorage {
6581 path: dir.path().join("auth.json"),
6582 entries: HashMap::new(),
6583 };
6584 auth.set(
6585 "amazon-bedrock",
6586 AuthCredential::BearerToken {
6587 token: "stored-bearer".to_string(),
6588 },
6589 );
6590 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
6591 assert_eq!(
6592 result,
6593 Some(AwsResolvedCredentials::Bearer {
6594 token: "stored-bearer".to_string(),
6595 region: "us-east-1".to_string(),
6596 })
6597 );
6598 }
6599
6600 #[test]
6601 fn test_aws_env_beats_stored() {
6602 let dir = tempfile::tempdir().expect("tmpdir");
6603 let mut auth = AuthStorage {
6604 path: dir.path().join("auth.json"),
6605 entries: HashMap::new(),
6606 };
6607 auth.set(
6608 "amazon-bedrock",
6609 AuthCredential::AwsCredentials {
6610 access_key_id: "AKIA_STORED".to_string(),
6611 secret_access_key: "stored_secret".to_string(),
6612 session_token: None,
6613 region: None,
6614 },
6615 );
6616 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6617 "AWS_ACCESS_KEY_ID" => Some("AKIA_ENV".to_string()),
6618 "AWS_SECRET_ACCESS_KEY" => Some("env_secret".to_string()),
6619 _ => None,
6620 });
6621 match result {
6622 Some(AwsResolvedCredentials::Sigv4 { access_key_id, .. }) => {
6623 assert_eq!(access_key_id, "AKIA_ENV");
6624 }
6625 other => panic!("expected Sigv4 from env, got: {other:?}"),
6626 }
6627 }
6628
6629 #[test]
6630 fn test_aws_no_credentials_returns_none() {
6631 let auth = empty_auth();
6632 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
6633 assert!(result.is_none());
6634 }
6635
6636 #[test]
6637 fn test_aws_empty_bearer_token_skipped() {
6638 let auth = empty_auth();
6639 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6640 "AWS_BEARER_TOKEN_BEDROCK" => Some(" ".to_string()),
6641 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
6642 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
6643 _ => None,
6644 });
6645 assert!(matches!(result, Some(AwsResolvedCredentials::Sigv4 { .. })));
6646 }
6647
6648 #[test]
6649 fn test_aws_access_key_without_secret_skipped() {
6650 let auth = empty_auth();
6651 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
6652 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
6653 _ => None,
6654 });
6655 assert!(result.is_none());
6656 }
6657
6658 #[test]
6661 fn test_sap_json_service_key() {
6662 let auth = empty_auth();
6663 let key_json = serde_json::json!({
6664 "clientid": "sap-client",
6665 "clientsecret": "sap-secret",
6666 "url": "https://auth.sap.example.com/oauth/token",
6667 "serviceurls": {
6668 "AI_API_URL": "https://api.ai.sap.example.com"
6669 }
6670 })
6671 .to_string();
6672 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
6673 "AICORE_SERVICE_KEY" => Some(key_json.clone()),
6674 _ => None,
6675 });
6676 assert_eq!(
6677 result,
6678 Some(SapResolvedCredentials {
6679 client_id: "sap-client".to_string(),
6680 client_secret: "sap-secret".to_string(),
6681 token_url: "https://auth.sap.example.com/oauth/token".to_string(),
6682 service_url: "https://api.ai.sap.example.com".to_string(),
6683 })
6684 );
6685 }
6686
6687 #[test]
6688 fn test_sap_individual_env_vars() {
6689 let auth = empty_auth();
6690 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
6691 "SAP_AI_CORE_CLIENT_ID" => Some("env-client".to_string()),
6692 "SAP_AI_CORE_CLIENT_SECRET" => Some("env-secret".to_string()),
6693 "SAP_AI_CORE_TOKEN_URL" => Some("https://token.sap.example.com".to_string()),
6694 "SAP_AI_CORE_SERVICE_URL" => Some("https://service.sap.example.com".to_string()),
6695 _ => None,
6696 });
6697 assert_eq!(
6698 result,
6699 Some(SapResolvedCredentials {
6700 client_id: "env-client".to_string(),
6701 client_secret: "env-secret".to_string(),
6702 token_url: "https://token.sap.example.com".to_string(),
6703 service_url: "https://service.sap.example.com".to_string(),
6704 })
6705 );
6706 }
6707
6708 #[test]
6709 fn test_sap_stored_service_key() {
6710 let dir = tempfile::tempdir().expect("tmpdir");
6711 let mut auth = AuthStorage {
6712 path: dir.path().join("auth.json"),
6713 entries: HashMap::new(),
6714 };
6715 auth.set(
6716 "sap-ai-core",
6717 AuthCredential::ServiceKey {
6718 client_id: Some("stored-id".to_string()),
6719 client_secret: Some("stored-secret".to_string()),
6720 token_url: Some("https://stored-token.sap.com".to_string()),
6721 service_url: Some("https://stored-api.sap.com".to_string()),
6722 },
6723 );
6724 let result = resolve_sap_credentials_with_env(&auth, |_| -> Option<String> { None });
6725 assert_eq!(
6726 result,
6727 Some(SapResolvedCredentials {
6728 client_id: "stored-id".to_string(),
6729 client_secret: "stored-secret".to_string(),
6730 token_url: "https://stored-token.sap.com".to_string(),
6731 service_url: "https://stored-api.sap.com".to_string(),
6732 })
6733 );
6734 }
6735
6736 #[test]
6737 fn test_sap_json_key_wins_over_individual_vars() {
6738 let key_json = serde_json::json!({
6739 "clientid": "json-client",
6740 "clientsecret": "json-secret",
6741 "url": "https://json-token.example.com",
6742 "serviceurls": {"AI_API_URL": "https://json-api.example.com"}
6743 })
6744 .to_string();
6745 let auth = empty_auth();
6746 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
6747 "AICORE_SERVICE_KEY" => Some(key_json.clone()),
6748 "SAP_AI_CORE_CLIENT_ID" => Some("env-client".to_string()),
6749 "SAP_AI_CORE_CLIENT_SECRET" => Some("env-secret".to_string()),
6750 "SAP_AI_CORE_TOKEN_URL" => Some("https://env-token.example.com".to_string()),
6751 "SAP_AI_CORE_SERVICE_URL" => Some("https://env-api.example.com".to_string()),
6752 _ => None,
6753 });
6754 assert_eq!(result.unwrap().client_id, "json-client");
6755 }
6756
6757 #[test]
6758 fn test_sap_incomplete_individual_vars_returns_none() {
6759 let auth = empty_auth();
6760 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
6761 "SAP_AI_CORE_CLIENT_ID" => Some("id".to_string()),
6762 "SAP_AI_CORE_CLIENT_SECRET" => Some("secret".to_string()),
6763 "SAP_AI_CORE_TOKEN_URL" => Some("https://token.example.com".to_string()),
6764 _ => None,
6765 });
6766 assert!(result.is_none());
6767 }
6768
6769 #[test]
6770 fn test_sap_invalid_json_falls_through() {
6771 let auth = empty_auth();
6772 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
6773 "AICORE_SERVICE_KEY" => Some("not-valid-json".to_string()),
6774 "SAP_AI_CORE_CLIENT_ID" => Some("env-id".to_string()),
6775 "SAP_AI_CORE_CLIENT_SECRET" => Some("env-secret".to_string()),
6776 "SAP_AI_CORE_TOKEN_URL" => Some("https://token.example.com".to_string()),
6777 "SAP_AI_CORE_SERVICE_URL" => Some("https://api.example.com".to_string()),
6778 _ => None,
6779 });
6780 assert_eq!(result.unwrap().client_id, "env-id");
6781 }
6782
6783 #[test]
6784 fn test_sap_no_credentials_returns_none() {
6785 let auth = empty_auth();
6786 let result = resolve_sap_credentials_with_env(&auth, |_| -> Option<String> { None });
6787 assert!(result.is_none());
6788 }
6789
6790 #[test]
6791 fn test_sap_json_key_alternate_field_names() {
6792 let key_json = serde_json::json!({
6793 "client_id": "alt-id",
6794 "client_secret": "alt-secret",
6795 "token_url": "https://alt-token.example.com",
6796 "service_url": "https://alt-api.example.com"
6797 })
6798 .to_string();
6799 let creds = parse_sap_service_key_json(&key_json);
6800 assert_eq!(
6801 creds,
6802 Some(SapResolvedCredentials {
6803 client_id: "alt-id".to_string(),
6804 client_secret: "alt-secret".to_string(),
6805 token_url: "https://alt-token.example.com".to_string(),
6806 service_url: "https://alt-api.example.com".to_string(),
6807 })
6808 );
6809 }
6810
6811 #[test]
6812 fn test_sap_json_key_missing_required_field_returns_none() {
6813 let key_json = serde_json::json!({
6814 "clientid": "id",
6815 "url": "https://token.example.com",
6816 "serviceurls": {"AI_API_URL": "https://api.example.com"}
6817 })
6818 .to_string();
6819 assert!(parse_sap_service_key_json(&key_json).is_none());
6820 }
6821
6822 #[test]
6825 fn test_sap_metadata_exists() {
6826 let keys = env_keys_for_provider("sap-ai-core");
6827 assert!(!keys.is_empty(), "sap-ai-core should have env keys");
6828 assert!(keys.contains(&"AICORE_SERVICE_KEY"));
6829 }
6830
6831 #[test]
6832 fn test_sap_alias_resolves() {
6833 let keys = env_keys_for_provider("sap");
6834 assert!(!keys.is_empty(), "sap alias should resolve");
6835 assert!(keys.contains(&"AICORE_SERVICE_KEY"));
6836 }
6837
6838 #[test]
6839 fn test_exchange_sap_access_token_with_client_success() {
6840 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6841 rt.expect("runtime").block_on(async {
6842 let token_response = r#"{"access_token":"sap-access-token"}"#;
6843 let token_url = spawn_json_server(200, token_response);
6844 let client = crate::http::client::Client::new();
6845 let creds = SapResolvedCredentials {
6846 client_id: "sap-client".to_string(),
6847 client_secret: "sap-secret".to_string(),
6848 token_url,
6849 service_url: "https://api.ai.sap.example.com".to_string(),
6850 };
6851
6852 let token = exchange_sap_access_token_with_client(&client, &creds)
6853 .await
6854 .expect("token exchange");
6855 assert_eq!(token, "sap-access-token");
6856 });
6857 }
6858
6859 #[test]
6860 fn test_exchange_sap_access_token_with_client_http_error() {
6861 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6862 rt.expect("runtime").block_on(async {
6863 let token_url = spawn_json_server(401, r#"{"error":"unauthorized"}"#);
6864 let client = crate::http::client::Client::new();
6865 let creds = SapResolvedCredentials {
6866 client_id: "sap-client".to_string(),
6867 client_secret: "sap-secret".to_string(),
6868 token_url,
6869 service_url: "https://api.ai.sap.example.com".to_string(),
6870 };
6871
6872 let err = exchange_sap_access_token_with_client(&client, &creds)
6873 .await
6874 .expect_err("expected HTTP error");
6875 assert!(
6876 err.to_string().contains("HTTP 401"),
6877 "unexpected error: {err}"
6878 );
6879 });
6880 }
6881
6882 #[test]
6883 fn test_exchange_sap_access_token_with_client_invalid_json() {
6884 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6885 rt.expect("runtime").block_on(async {
6886 let token_url = spawn_json_server(200, r#"{"token":"missing-access-token"}"#);
6887 let client = crate::http::client::Client::new();
6888 let creds = SapResolvedCredentials {
6889 client_id: "sap-client".to_string(),
6890 client_secret: "sap-secret".to_string(),
6891 token_url,
6892 service_url: "https://api.ai.sap.example.com".to_string(),
6893 };
6894
6895 let err = exchange_sap_access_token_with_client(&client, &creds)
6896 .await
6897 .expect_err("expected JSON error");
6898 assert!(
6899 err.to_string().contains("invalid JSON"),
6900 "unexpected error: {err}"
6901 );
6902 });
6903 }
6904
6905 #[test]
6908 fn test_proactive_refresh_triggers_within_window() {
6909 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6910 rt.expect("runtime").block_on(async {
6911 let dir = tempfile::tempdir().expect("tmpdir");
6912 let auth_path = dir.path().join("auth.json");
6913
6914 let five_min_from_now = chrono::Utc::now().timestamp_millis() + 5 * 60 * 1000;
6916 let token_response =
6917 r#"{"access_token":"refreshed","refresh_token":"new-ref","expires_in":3600}"#;
6918 let server_url = spawn_json_server(200, token_response);
6919
6920 let mut auth = AuthStorage {
6921 path: auth_path,
6922 entries: HashMap::new(),
6923 };
6924 auth.entries.insert(
6925 "copilot".to_string(),
6926 AuthCredential::OAuth {
6927 access_token: "about-to-expire".to_string(),
6928 refresh_token: "old-ref".to_string(),
6929 expires: five_min_from_now,
6930 token_url: Some(server_url),
6931 client_id: Some("test-client".to_string()),
6932 },
6933 );
6934
6935 let client = crate::http::client::Client::new();
6936 auth.refresh_expired_oauth_tokens_with_client(&client)
6937 .await
6938 .expect("proactive refresh");
6939
6940 match auth.entries.get("copilot").expect("credential") {
6941 AuthCredential::OAuth { access_token, .. } => {
6942 assert_eq!(access_token, "refreshed");
6943 }
6944 other => panic!("expected OAuth, got: {other:?}"),
6945 }
6946 });
6947 }
6948
6949 #[test]
6950 fn test_proactive_refresh_skips_tokens_far_from_expiry() {
6951 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6952 rt.expect("runtime").block_on(async {
6953 let dir = tempfile::tempdir().expect("tmpdir");
6954 let auth_path = dir.path().join("auth.json");
6955
6956 let one_hour_from_now = chrono::Utc::now().timestamp_millis() + 60 * 60 * 1000;
6957
6958 let mut auth = AuthStorage {
6959 path: auth_path,
6960 entries: HashMap::new(),
6961 };
6962 auth.entries.insert(
6963 "copilot".to_string(),
6964 AuthCredential::OAuth {
6965 access_token: "still-good".to_string(),
6966 refresh_token: "ref".to_string(),
6967 expires: one_hour_from_now,
6968 token_url: Some("https://should-not-be-called.example.com/token".to_string()),
6969 client_id: Some("test-client".to_string()),
6970 },
6971 );
6972
6973 let client = crate::http::client::Client::new();
6974 auth.refresh_expired_oauth_tokens_with_client(&client)
6975 .await
6976 .expect("no refresh needed");
6977
6978 match auth.entries.get("copilot").expect("credential") {
6979 AuthCredential::OAuth { access_token, .. } => {
6980 assert_eq!(access_token, "still-good");
6981 }
6982 other => panic!("expected OAuth, got: {other:?}"),
6983 }
6984 });
6985 }
6986
6987 #[test]
6988 fn test_self_contained_refresh_uses_stored_metadata() {
6989 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6990 rt.expect("runtime").block_on(async {
6991 let dir = tempfile::tempdir().expect("tmpdir");
6992 let auth_path = dir.path().join("auth.json");
6993
6994 let token_response =
6995 r#"{"access_token":"new-copilot-token","refresh_token":"new-ref","expires_in":28800}"#;
6996 let server_url = spawn_json_server(200, token_response);
6997
6998 let mut auth = AuthStorage {
6999 path: auth_path,
7000 entries: HashMap::new(),
7001 };
7002 auth.entries.insert(
7003 "copilot".to_string(),
7004 AuthCredential::OAuth {
7005 access_token: "expired-copilot".to_string(),
7006 refresh_token: "old-ref".to_string(),
7007 expires: 0,
7008 token_url: Some(server_url.clone()),
7009 client_id: Some("Iv1.copilot-client".to_string()),
7010 },
7011 );
7012
7013 let client = crate::http::client::Client::new();
7014 auth.refresh_expired_oauth_tokens_with_client(&client)
7015 .await
7016 .expect("self-contained refresh");
7017
7018 match auth.entries.get("copilot").expect("credential") {
7019 AuthCredential::OAuth {
7020 access_token,
7021 token_url,
7022 client_id,
7023 ..
7024 } => {
7025 assert_eq!(access_token, "new-copilot-token");
7026 assert_eq!(token_url.as_deref(), Some(server_url.as_str()));
7027 assert_eq!(client_id.as_deref(), Some("Iv1.copilot-client"));
7028 }
7029 other => panic!("expected OAuth, got: {other:?}"),
7030 }
7031 });
7032 }
7033
7034 #[test]
7035 fn test_self_contained_refresh_skips_when_no_metadata() {
7036 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
7037 rt.expect("runtime").block_on(async {
7038 let dir = tempfile::tempdir().expect("tmpdir");
7039 let auth_path = dir.path().join("auth.json");
7040
7041 let mut auth = AuthStorage {
7042 path: auth_path,
7043 entries: HashMap::new(),
7044 };
7045 auth.entries.insert(
7046 "ext-custom".to_string(),
7047 AuthCredential::OAuth {
7048 access_token: "old-ext".to_string(),
7049 refresh_token: "ref".to_string(),
7050 expires: 0,
7051 token_url: None,
7052 client_id: None,
7053 },
7054 );
7055
7056 let client = crate::http::client::Client::new();
7057 auth.refresh_expired_oauth_tokens_with_client(&client)
7058 .await
7059 .expect("should succeed by skipping");
7060
7061 match auth.entries.get("ext-custom").expect("credential") {
7062 AuthCredential::OAuth { access_token, .. } => {
7063 assert_eq!(access_token, "old-ext");
7064 }
7065 other => panic!("expected OAuth, got: {other:?}"),
7066 }
7067 });
7068 }
7069
7070 #[test]
7071 fn test_extension_refresh_skips_self_contained_credentials() {
7072 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
7073 rt.expect("runtime").block_on(async {
7074 let dir = tempfile::tempdir().expect("tmpdir");
7075 let auth_path = dir.path().join("auth.json");
7076
7077 let mut auth = AuthStorage {
7078 path: auth_path,
7079 entries: HashMap::new(),
7080 };
7081 auth.entries.insert(
7082 "copilot".to_string(),
7083 AuthCredential::OAuth {
7084 access_token: "self-contained".to_string(),
7085 refresh_token: "ref".to_string(),
7086 expires: 0,
7087 token_url: Some("https://github.com/login/oauth/access_token".to_string()),
7088 client_id: Some("Iv1.copilot".to_string()),
7089 },
7090 );
7091
7092 let client = crate::http::client::Client::new();
7093 let mut extension_configs = HashMap::new();
7094 extension_configs.insert("copilot".to_string(), sample_oauth_config());
7095
7096 auth.refresh_expired_extension_oauth_tokens(&client, &extension_configs)
7097 .await
7098 .expect("should succeed by skipping");
7099
7100 match auth.entries.get("copilot").expect("credential") {
7101 AuthCredential::OAuth { access_token, .. } => {
7102 assert_eq!(access_token, "self-contained");
7103 }
7104 other => panic!("expected OAuth, got: {other:?}"),
7105 }
7106 });
7107 }
7108
7109 #[test]
7110 fn test_prune_stale_credentials_removes_old_expired_without_metadata() {
7111 let dir = tempfile::tempdir().expect("tmpdir");
7112 let auth_path = dir.path().join("auth.json");
7113
7114 let mut auth = AuthStorage {
7115 path: auth_path,
7116 entries: HashMap::new(),
7117 };
7118
7119 let now = chrono::Utc::now().timestamp_millis();
7120 let one_day_ms = 24 * 60 * 60 * 1000;
7121
7122 auth.entries.insert(
7124 "stale-ext".to_string(),
7125 AuthCredential::OAuth {
7126 access_token: "dead".to_string(),
7127 refresh_token: "dead-ref".to_string(),
7128 expires: now - 2 * one_day_ms,
7129 token_url: None,
7130 client_id: None,
7131 },
7132 );
7133
7134 auth.entries.insert(
7136 "copilot".to_string(),
7137 AuthCredential::OAuth {
7138 access_token: "old-copilot".to_string(),
7139 refresh_token: "ref".to_string(),
7140 expires: now - 2 * one_day_ms,
7141 token_url: Some("https://github.com/login/oauth/access_token".to_string()),
7142 client_id: Some("Iv1.copilot".to_string()),
7143 },
7144 );
7145
7146 auth.entries.insert(
7148 "recent-ext".to_string(),
7149 AuthCredential::OAuth {
7150 access_token: "recent".to_string(),
7151 refresh_token: "ref".to_string(),
7152 expires: now - 30 * 60 * 1000, token_url: None,
7154 client_id: None,
7155 },
7156 );
7157
7158 auth.entries.insert(
7160 "anthropic".to_string(),
7161 AuthCredential::ApiKey {
7162 key: "sk-test".to_string(),
7163 },
7164 );
7165
7166 let pruned = auth.prune_stale_credentials(one_day_ms);
7167
7168 assert_eq!(pruned, vec!["stale-ext"]);
7169 assert!(!auth.entries.contains_key("stale-ext"));
7170 assert!(auth.entries.contains_key("copilot"));
7171 assert!(auth.entries.contains_key("recent-ext"));
7172 assert!(auth.entries.contains_key("anthropic"));
7173 }
7174
7175 #[test]
7176 fn test_prune_stale_credentials_no_op_when_all_valid() {
7177 let dir = tempfile::tempdir().expect("tmpdir");
7178 let auth_path = dir.path().join("auth.json");
7179
7180 let mut auth = AuthStorage {
7181 path: auth_path,
7182 entries: HashMap::new(),
7183 };
7184
7185 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
7186 auth.entries.insert(
7187 "ext-prov".to_string(),
7188 AuthCredential::OAuth {
7189 access_token: "valid".to_string(),
7190 refresh_token: "ref".to_string(),
7191 expires: far_future,
7192 token_url: None,
7193 client_id: None,
7194 },
7195 );
7196
7197 let pruned = auth.prune_stale_credentials(24 * 60 * 60 * 1000);
7198 assert!(pruned.is_empty());
7199 assert!(auth.entries.contains_key("ext-prov"));
7200 }
7201
7202 #[test]
7203 fn test_credential_serialization_preserves_new_fields() {
7204 let cred = AuthCredential::OAuth {
7205 access_token: "tok".to_string(),
7206 refresh_token: "ref".to_string(),
7207 expires: 12345,
7208 token_url: Some("https://example.com/token".to_string()),
7209 client_id: Some("my-client".to_string()),
7210 };
7211
7212 let json = serde_json::to_string(&cred).expect("serialize");
7213 assert!(json.contains("token_url"));
7214 assert!(json.contains("client_id"));
7215
7216 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
7217 match parsed {
7218 AuthCredential::OAuth {
7219 token_url,
7220 client_id,
7221 ..
7222 } => {
7223 assert_eq!(token_url.as_deref(), Some("https://example.com/token"));
7224 assert_eq!(client_id.as_deref(), Some("my-client"));
7225 }
7226 other => panic!("expected OAuth, got: {other:?}"),
7227 }
7228 }
7229
7230 #[test]
7231 fn test_credential_serialization_omits_none_fields() {
7232 let cred = AuthCredential::OAuth {
7233 access_token: "tok".to_string(),
7234 refresh_token: "ref".to_string(),
7235 expires: 12345,
7236 token_url: None,
7237 client_id: None,
7238 };
7239
7240 let json = serde_json::to_string(&cred).expect("serialize");
7241 assert!(!json.contains("token_url"));
7242 assert!(!json.contains("client_id"));
7243 }
7244
7245 #[test]
7246 fn test_credential_deserialization_defaults_missing_fields() {
7247 let json =
7248 r#"{"type":"o_auth","access_token":"tok","refresh_token":"ref","expires":12345}"#;
7249 let parsed: AuthCredential = serde_json::from_str(json).expect("deserialize");
7250 match parsed {
7251 AuthCredential::OAuth {
7252 token_url,
7253 client_id,
7254 ..
7255 } => {
7256 assert!(token_url.is_none());
7257 assert!(client_id.is_none());
7258 }
7259 other => panic!("expected OAuth, got: {other:?}"),
7260 }
7261 }
7262
7263 #[test]
7264 fn codex_openai_api_key_parser_ignores_oauth_access_token_only_payloads() {
7265 let value = serde_json::json!({
7266 "tokens": {
7267 "access_token": "codex-oauth-token"
7268 }
7269 });
7270 assert!(codex_openai_api_key_from_value(&value).is_none());
7271 }
7272
7273 #[test]
7274 fn codex_access_token_parser_reads_nested_tokens_payload() {
7275 let value = serde_json::json!({
7276 "tokens": {
7277 "access_token": " codex-oauth-token "
7278 }
7279 });
7280 assert_eq!(
7281 codex_access_token_from_value(&value).as_deref(),
7282 Some("codex-oauth-token")
7283 );
7284 }
7285
7286 #[test]
7287 fn codex_openai_api_key_parser_reads_openai_api_key_field() {
7288 let value = serde_json::json!({
7289 "OPENAI_API_KEY": " sk-openai "
7290 });
7291 assert_eq!(
7292 codex_openai_api_key_from_value(&value).as_deref(),
7293 Some("sk-openai")
7294 );
7295 }
7296}