1use crate::config::Config;
6use crate::error::{Error, Result};
7use crate::provider_metadata::{canonical_provider_id, provider_auth_env_keys, provider_metadata};
8use base64::Engine as _;
9use fs4::fs_std::FileExt;
10use serde::{Deserialize, Serialize};
11use sha2::Digest as _;
12use std::collections::HashMap;
13use std::fmt::Write as _;
14use std::fs::{self, File};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::time::{Duration, Instant};
18use tempfile::NamedTempFile;
19
20const ANTHROPIC_OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
21const ANTHROPIC_OAUTH_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
22const ANTHROPIC_OAUTH_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
23const ANTHROPIC_OAUTH_REDIRECT_URI: &str = "https://console.anthropic.com/oauth/code/callback";
24const ANTHROPIC_OAUTH_SCOPES: &str = "org:create_api_key user:profile user:inference";
25
26const OPENAI_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
28const OPENAI_CODEX_OAUTH_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
29const OPENAI_CODEX_OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
30const OPENAI_CODEX_OAUTH_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
31const OPENAI_CODEX_OAUTH_SCOPES: &str = "openid profile email offline_access";
32
33const GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID: &str =
35 "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
36const GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET: &str = "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";
37const GOOGLE_GEMINI_CLI_OAUTH_REDIRECT_URI: &str = "http://localhost:8085/oauth2callback";
38const 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";
39const GOOGLE_GEMINI_CLI_OAUTH_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
40const GOOGLE_GEMINI_CLI_OAUTH_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
41const GOOGLE_GEMINI_CLI_CODE_ASSIST_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com";
42
43const GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID: &str =
45 "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
46const GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET: &str = "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";
47const GOOGLE_ANTIGRAVITY_OAUTH_REDIRECT_URI: &str = "http://localhost:51121/oauth-callback";
48const 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";
49const GOOGLE_ANTIGRAVITY_OAUTH_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
50const GOOGLE_ANTIGRAVITY_OAUTH_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
51const GOOGLE_ANTIGRAVITY_DEFAULT_PROJECT_ID: &str = "rising-fact-p41fc";
52const GOOGLE_ANTIGRAVITY_PROJECT_DISCOVERY_ENDPOINTS: [&str; 2] = [
53 "https://cloudcode-pa.googleapis.com",
54 "https://daily-cloudcode-pa.sandbox.googleapis.com",
55];
56
57const ANTHROPIC_OAUTH_BEARER_MARKER: &str = "__pi_anthropic_oauth_bearer__:";
60
61const GITHUB_OAUTH_AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
63const GITHUB_OAUTH_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
64const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
65const GITHUB_COPILOT_SCOPES: &str = "read:user";
67
68const GITLAB_OAUTH_AUTHORIZE_PATH: &str = "/oauth/authorize";
70const GITLAB_OAUTH_TOKEN_PATH: &str = "/oauth/token";
71const GITLAB_DEFAULT_BASE_URL: &str = "https://gitlab.com";
72const GITLAB_DEFAULT_SCOPES: &str = "api read_api read_user";
74
75const KIMI_CODE_OAUTH_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
77const KIMI_CODE_OAUTH_DEFAULT_HOST: &str = "https://auth.kimi.com";
78const KIMI_CODE_OAUTH_HOST_ENV_KEYS: [&str; 2] = ["KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST"];
79const KIMI_SHARE_DIR_ENV_KEY: &str = "KIMI_SHARE_DIR";
80const KIMI_CODE_DEVICE_AUTHORIZATION_PATH: &str = "/api/oauth/device_authorization";
81const KIMI_CODE_TOKEN_PATH: &str = "/api/oauth/token";
82
83fn oauth_param(env_key: &str, default: &str) -> String {
92 match std::env::var(env_key) {
93 Ok(val) if !val.is_empty() => {
94 tracing::debug!(env_key, "Using env override for OAuth parameter");
95 val
96 }
97 _ => default.to_string(),
98 }
99}
100
101fn anthropic_oauth_client_id() -> String {
106 oauth_param("PI_ANTHROPIC_OAUTH_CLIENT_ID", ANTHROPIC_OAUTH_CLIENT_ID)
107}
108fn anthropic_oauth_authorize_url() -> String {
109 oauth_param(
110 "PI_ANTHROPIC_OAUTH_AUTHORIZE_URL",
111 ANTHROPIC_OAUTH_AUTHORIZE_URL,
112 )
113}
114fn anthropic_oauth_token_url() -> String {
115 oauth_param("PI_ANTHROPIC_OAUTH_TOKEN_URL", ANTHROPIC_OAUTH_TOKEN_URL)
116}
117fn anthropic_oauth_redirect_uri() -> String {
118 oauth_param(
119 "PI_ANTHROPIC_OAUTH_REDIRECT_URI",
120 ANTHROPIC_OAUTH_REDIRECT_URI,
121 )
122}
123fn anthropic_oauth_scopes() -> String {
124 oauth_param("PI_ANTHROPIC_OAUTH_SCOPES", ANTHROPIC_OAUTH_SCOPES)
125}
126
127fn openai_codex_oauth_client_id() -> String {
128 oauth_param(
129 "PI_OPENAI_CODEX_OAUTH_CLIENT_ID",
130 OPENAI_CODEX_OAUTH_CLIENT_ID,
131 )
132}
133fn openai_codex_oauth_authorize_url() -> String {
134 oauth_param(
135 "PI_OPENAI_CODEX_OAUTH_AUTHORIZE_URL",
136 OPENAI_CODEX_OAUTH_AUTHORIZE_URL,
137 )
138}
139fn openai_codex_oauth_token_url() -> String {
140 oauth_param(
141 "PI_OPENAI_CODEX_OAUTH_TOKEN_URL",
142 OPENAI_CODEX_OAUTH_TOKEN_URL,
143 )
144}
145fn openai_codex_oauth_redirect_uri() -> String {
146 oauth_param(
147 "PI_OPENAI_CODEX_OAUTH_REDIRECT_URI",
148 OPENAI_CODEX_OAUTH_REDIRECT_URI,
149 )
150}
151fn openai_codex_oauth_scopes() -> String {
152 oauth_param("PI_OPENAI_CODEX_OAUTH_SCOPES", OPENAI_CODEX_OAUTH_SCOPES)
153}
154
155fn google_gemini_cli_oauth_client_id() -> String {
156 oauth_param(
157 "PI_GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID",
158 GOOGLE_GEMINI_CLI_OAUTH_CLIENT_ID,
159 )
160}
161fn google_gemini_cli_oauth_client_secret() -> String {
162 oauth_param(
163 "PI_GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET",
164 GOOGLE_GEMINI_CLI_OAUTH_CLIENT_SECRET,
165 )
166}
167fn google_gemini_cli_oauth_redirect_uri() -> String {
168 oauth_param(
169 "PI_GOOGLE_GEMINI_CLI_OAUTH_REDIRECT_URI",
170 GOOGLE_GEMINI_CLI_OAUTH_REDIRECT_URI,
171 )
172}
173
174fn google_antigravity_oauth_client_id() -> String {
175 oauth_param(
176 "PI_GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID",
177 GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_ID,
178 )
179}
180fn google_antigravity_oauth_client_secret() -> String {
181 oauth_param(
182 "PI_GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET",
183 GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET,
184 )
185}
186fn google_antigravity_oauth_redirect_uri() -> String {
187 oauth_param(
188 "PI_GOOGLE_ANTIGRAVITY_OAUTH_REDIRECT_URI",
189 GOOGLE_ANTIGRAVITY_OAUTH_REDIRECT_URI,
190 )
191}
192fn google_antigravity_default_project_id() -> String {
193 oauth_param(
194 "PI_GOOGLE_ANTIGRAVITY_PROJECT_ID",
195 GOOGLE_ANTIGRAVITY_DEFAULT_PROJECT_ID,
196 )
197}
198
199fn kimi_code_oauth_client_id() -> String {
200 oauth_param("PI_KIMI_CODE_OAUTH_CLIENT_ID", KIMI_CODE_OAUTH_CLIENT_ID)
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "type", rename_all = "snake_case")]
206pub enum AuthCredential {
207 ApiKey {
208 key: String,
209 },
210 OAuth {
211 access_token: String,
212 refresh_token: String,
213 expires: i64, #[serde(default, skip_serializing_if = "Option::is_none")]
216 token_url: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 client_id: Option<String>,
220 },
221 AwsCredentials {
226 access_key_id: String,
227 secret_access_key: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 session_token: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 region: Option<String>,
232 },
233 BearerToken {
238 token: String,
239 },
240 ServiceKey {
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 client_id: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 client_secret: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 token_url: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 service_url: Option<String>,
251 },
252 #[serde(untagged)]
255 Unknown(serde_json::Value),
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum CredentialStatus {
261 Missing,
262 ApiKey,
263 OAuthValid { expires_in_ms: i64 },
264 OAuthExpired { expired_by_ms: i64 },
265 BearerToken,
266 AwsCredentials,
267 ServiceKey,
268}
269
270const PROACTIVE_REFRESH_WINDOW_MS: i64 = 10 * 60 * 1000; type OAuthRefreshRequest = (String, String, String, Option<String>, Option<String>);
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276pub struct AuthFile {
277 #[serde(flatten)]
278 pub entries: HashMap<String, AuthCredential>,
279}
280
281#[derive(Serialize)]
282struct AuthFileRef<'a> {
283 #[serde(flatten)]
284 entries: &'a HashMap<String, AuthCredential>,
285}
286
287#[derive(Debug, Clone)]
289pub struct AuthStorage {
290 path: PathBuf,
291 entries: HashMap<String, AuthCredential>,
292}
293
294impl AuthStorage {
295 fn allow_external_provider_lookup(&self) -> bool {
296 self.path == Config::auth_path()
300 }
301
302 fn entry_case_insensitive(&self, key: &str) -> Option<&AuthCredential> {
303 self.entries.iter().find_map(|(existing, credential)| {
304 existing.eq_ignore_ascii_case(key).then_some(credential)
305 })
306 }
307
308 fn credential_for_provider(&self, provider: &str) -> Option<&AuthCredential> {
309 if let Some(credential) = self
310 .entries
311 .get(provider)
312 .or_else(|| self.entry_case_insensitive(provider))
313 {
314 return Some(credential);
315 }
316
317 let metadata = provider_metadata(provider)?;
318 if let Some(credential) = self
319 .entries
320 .get(metadata.canonical_id)
321 .or_else(|| self.entry_case_insensitive(metadata.canonical_id))
322 {
323 return Some(credential);
324 }
325
326 metadata.aliases.iter().find_map(|alias| {
327 self.entries
328 .get(*alias)
329 .or_else(|| self.entry_case_insensitive(alias))
330 })
331 }
332
333 pub fn load(path: PathBuf) -> Result<Self> {
335 let entries = if path.exists() {
336 let lock_handle = open_auth_lock_file(&path)?;
337 let _locked = lock_file_shared(lock_handle, Duration::from_secs(30))?;
338 let content =
339 fs::read_to_string(&path).map_err(|e| Error::auth(format!("auth.json: {e}")))?;
340 let parsed: AuthFile = match serde_json::from_str(&content) {
341 Ok(file) => file,
342 Err(e) => {
343 let backup_path = path.with_extension("json.corrupt");
344 if let Err(backup_err) = fs::copy(&path, &backup_path) {
345 tracing::error!(
346 event = "pi.auth.backup_failed",
347 error = %backup_err,
348 backup = %backup_path.display(),
349 "Failed to backup corrupted auth.json"
350 );
351 }
352 tracing::warn!(
353 event = "pi.auth.parse_error",
354 error = %e,
355 backup = %backup_path.display(),
356 "auth.json is corrupted; backed up and starting with empty credentials"
357 );
358 AuthFile::default()
359 }
360 };
361 parsed.entries
362 } else {
363 HashMap::new()
364 };
365
366 Ok(Self { path, entries })
367 }
368
369 pub async fn load_async(path: PathBuf) -> Result<Self> {
371 asupersync::runtime::spawn_blocking(move || Self::load(path)).await
372 }
373
374 pub fn save(&self) -> Result<()> {
376 let data = serde_json::to_string_pretty(&AuthFileRef {
377 entries: &self.entries,
378 })?;
379 Self::save_data_sync(&self.path, &data)
380 }
381
382 pub async fn save_async(&self) -> Result<()> {
384 let data = serde_json::to_string_pretty(&AuthFileRef {
385 entries: &self.entries,
386 })?;
387 let path = self.path.clone();
388
389 asupersync::runtime::spawn_blocking(move || Self::save_data_sync(&path, &data)).await
390 }
391
392 fn save_data_sync(path: &Path, data: &str) -> Result<()> {
393 Self::save_data_sync_with_hook(path, data, |_| Ok(()))
394 }
395
396 fn save_data_sync_with_hook<F>(path: &Path, data: &str, before_persist: F) -> Result<()>
397 where
398 F: FnOnce(&NamedTempFile) -> std::io::Result<()>,
399 {
400 if let Some(parent) = path.parent() {
401 fs::create_dir_all(parent)?;
402 }
403
404 let lock_handle = open_auth_lock_file(path)?;
405 let _locked = lock_file(lock_handle, Duration::from_secs(30))?;
406
407 let parent = path.parent().unwrap_or_else(|| Path::new("."));
408 let mut temp = NamedTempFile::new_in(parent)?;
409
410 #[cfg(unix)]
411 {
412 use std::os::unix::fs::PermissionsExt as _;
413 temp.as_file()
414 .set_permissions(fs::Permissions::from_mode(0o600))?;
415 }
416
417 temp.write_all(data.as_bytes())?;
418 temp.as_file().sync_all()?;
419 before_persist(&temp)?;
420 temp.persist(path).map_err(|err| err.error)?;
421 sync_parent_dir(path)?;
422
423 Ok(())
424 }
425 pub fn get(&self, provider: &str) -> Option<&AuthCredential> {
427 self.entries.get(provider)
428 }
429
430 pub fn set(&mut self, provider: impl Into<String>, credential: AuthCredential) {
432 self.entries.insert(provider.into(), credential);
433 }
434
435 pub fn remove(&mut self, provider: &str) -> bool {
437 self.entries.remove(provider).is_some()
438 }
439
440 pub fn api_key(&self, provider: &str) -> Option<String> {
448 self.credential_for_provider(provider)
449 .and_then(api_key_from_credential)
450 }
451
452 pub fn provider_names(&self) -> Vec<String> {
454 let mut providers: Vec<String> = self.entries.keys().cloned().collect();
455 providers.sort();
456 providers
457 }
458
459 pub fn credential_status(&self, provider: &str) -> CredentialStatus {
461 let now = chrono::Utc::now().timestamp_millis();
462 let cred = self.credential_for_provider(provider);
463
464 let Some(cred) = cred else {
465 return if self.allow_external_provider_lookup()
466 && resolve_external_provider_api_key(provider).is_some()
467 {
468 CredentialStatus::ApiKey
469 } else {
470 CredentialStatus::Missing
471 };
472 };
473
474 match cred {
475 AuthCredential::ApiKey { .. } => CredentialStatus::ApiKey,
476 AuthCredential::OAuth { expires, .. } if *expires > now => {
477 CredentialStatus::OAuthValid {
478 expires_in_ms: expires.saturating_sub(now),
479 }
480 }
481 AuthCredential::OAuth { expires, .. } => CredentialStatus::OAuthExpired {
482 expired_by_ms: now.saturating_sub(*expires),
483 },
484 AuthCredential::BearerToken { .. } => CredentialStatus::BearerToken,
485 AuthCredential::AwsCredentials { .. } => CredentialStatus::AwsCredentials,
486 AuthCredential::ServiceKey { .. } => CredentialStatus::ServiceKey,
487 AuthCredential::Unknown(_) => CredentialStatus::Missing,
488 }
489 }
490
491 pub fn remove_provider_aliases(&mut self, provider: &str) -> bool {
495 let trimmed = provider.trim();
496 if trimmed.is_empty() {
497 return false;
498 }
499
500 let mut targets: Vec<String> = vec![trimmed.to_ascii_lowercase()];
501 if let Some(metadata) = provider_metadata(trimmed) {
502 targets.push(metadata.canonical_id.to_ascii_lowercase());
503 targets.extend(
504 metadata
505 .aliases
506 .iter()
507 .map(|alias| alias.to_ascii_lowercase()),
508 );
509 }
510 targets.sort();
511 targets.dedup();
512
513 let mut removed = false;
514 self.entries.retain(|key, _| {
515 let should_remove = targets
516 .iter()
517 .any(|target| key.eq_ignore_ascii_case(target));
518 if should_remove {
519 removed = true;
520 }
521 !should_remove
522 });
523 removed
524 }
525
526 pub fn has_stored_credential(&self, provider: &str) -> bool {
529 self.credential_for_provider(provider).is_some()
530 }
531
532 pub fn external_setup_source(&self, provider: &str) -> Option<&'static str> {
535 if !self.allow_external_provider_lookup() {
536 return None;
537 }
538 external_setup_source(provider)
539 }
540
541 pub fn resolve_api_key(&self, provider: &str, override_key: Option<&str>) -> Option<String> {
543 self.resolve_api_key_with_env_lookup(provider, override_key, |var| std::env::var(var).ok())
544 }
545
546 fn resolve_api_key_with_env_lookup<F>(
547 &self,
548 provider: &str,
549 override_key: Option<&str>,
550 mut env_lookup: F,
551 ) -> Option<String>
552 where
553 F: FnMut(&str) -> Option<String>,
554 {
555 if let Some(key) = override_key {
556 let trimmed = key.trim();
557 if !trimmed.is_empty() {
558 return Some(trimmed.to_string());
559 }
560 }
561
562 if let Some(credential) = self.credential_for_provider(provider)
565 && let Some(key) = match credential {
566 AuthCredential::OAuth { .. }
567 if canonical_provider_id(provider).unwrap_or(provider) == "anthropic" =>
568 {
569 api_key_from_credential(credential)
570 .map(|token| mark_anthropic_oauth_bearer_token(&token))
571 }
572 AuthCredential::OAuth { .. } | AuthCredential::BearerToken { .. } => {
573 api_key_from_credential(credential)
574 }
575 _ => None,
576 }
577 {
578 return Some(key);
579 }
580
581 if let Some(key) = env_keys_for_provider(provider).iter().find_map(|var| {
582 env_lookup(var).and_then(|value| {
583 let trimmed = value.trim();
584 if trimmed.is_empty() {
585 None
586 } else {
587 Some(trimmed.to_string())
588 }
589 })
590 }) {
591 return Some(key);
592 }
593
594 if let Some(key) = self.api_key(provider) {
595 return Some(key);
596 }
597
598 if self.allow_external_provider_lookup() {
599 if let Some(key) = resolve_external_provider_api_key(provider) {
600 return Some(key);
601 }
602 }
603
604 canonical_provider_id(provider)
605 .filter(|canonical| *canonical != provider)
606 .and_then(|canonical| {
607 self.api_key(canonical).or_else(|| {
608 self.allow_external_provider_lookup()
609 .then(|| resolve_external_provider_api_key(canonical))
610 .flatten()
611 })
612 })
613 }
614
615 pub async fn refresh_expired_oauth_tokens(&mut self) -> Result<()> {
620 let client = crate::http::client::Client::new();
621 self.refresh_expired_oauth_tokens_with_client(&client).await
622 }
623
624 #[allow(clippy::too_many_lines)]
629 pub async fn refresh_expired_oauth_tokens_with_client(
630 &mut self,
631 client: &crate::http::client::Client,
632 ) -> Result<()> {
633 let now = chrono::Utc::now().timestamp_millis();
634 let proactive_deadline = now + PROACTIVE_REFRESH_WINDOW_MS;
635 let mut refreshes: Vec<OAuthRefreshRequest> = Vec::new();
636
637 for (provider, cred) in &self.entries {
638 if let AuthCredential::OAuth {
639 access_token,
640 refresh_token,
641 expires,
642 token_url,
643 client_id,
644 ..
645 } = cred
646 {
647 if *expires <= proactive_deadline {
650 refreshes.push((
651 provider.clone(),
652 access_token.clone(),
653 refresh_token.clone(),
654 token_url.clone(),
655 client_id.clone(),
656 ));
657 }
658 }
659 }
660
661 let mut failed_providers = Vec::new();
662 let mut needs_save = false;
663
664 for (provider, access_token, refresh_token, stored_token_url, stored_client_id) in refreshes
665 {
666 let result = match provider.as_str() {
667 "anthropic" => {
668 Box::pin(refresh_anthropic_oauth_token(client, &refresh_token)).await
669 }
670 "google-gemini-cli" => match decode_project_scoped_access_token(&access_token) {
671 Some((_, project_id)) => {
672 Box::pin(refresh_google_gemini_cli_oauth_token(
673 client,
674 &refresh_token,
675 &project_id,
676 ))
677 .await
678 }
679 None => Err(Error::auth(
680 "google-gemini-cli OAuth credential missing projectId payload".to_string(),
681 )),
682 },
683 "google-antigravity" => match decode_project_scoped_access_token(&access_token) {
684 Some((_, project_id)) => {
685 Box::pin(refresh_google_antigravity_oauth_token(
686 client,
687 &refresh_token,
688 &project_id,
689 ))
690 .await
691 }
692 None => Err(Error::auth(
693 "google-antigravity OAuth credential missing projectId payload".to_string(),
694 )),
695 },
696 "kimi-for-coding" => {
697 let token_url = stored_token_url
698 .clone()
699 .unwrap_or_else(kimi_code_token_endpoint);
700 Box::pin(refresh_kimi_code_oauth_token(
701 client,
702 &token_url,
703 &refresh_token,
704 ))
705 .await
706 }
707 _ => {
708 if let (Some(url), Some(cid)) = (&stored_token_url, &stored_client_id) {
709 Box::pin(refresh_self_contained_oauth_token(
710 client,
711 url,
712 cid,
713 &refresh_token,
714 &provider,
715 ))
716 .await
717 } else {
718 continue;
720 }
721 }
722 };
723
724 match result {
725 Ok(refreshed) => {
726 self.entries.insert(provider, refreshed);
727 needs_save = true;
728 }
729 Err(e) => {
730 tracing::warn!("Failed to refresh OAuth token for {provider}: {e}");
731 failed_providers.push(format!("{provider} ({e})"));
732 }
733 }
734 }
735
736 if needs_save {
737 if let Err(e) = self.save_async().await {
738 tracing::warn!("Failed to save auth.json after refreshing OAuth tokens: {e}");
739 }
740 }
741
742 if !failed_providers.is_empty() {
743 return Err(Error::auth(format!(
746 "OAuth token refresh failed for: {}",
747 failed_providers.join(", ")
748 )));
749 }
750
751 Ok(())
752 }
753
754 pub async fn refresh_expired_extension_oauth_tokens(
760 &mut self,
761 client: &crate::http::client::Client,
762 extension_configs: &HashMap<String, crate::models::OAuthConfig>,
763 ) -> Result<()> {
764 let now = chrono::Utc::now().timestamp_millis();
765 let proactive_deadline = now + PROACTIVE_REFRESH_WINDOW_MS;
766 let mut refreshes = Vec::new();
767
768 for (provider, cred) in &self.entries {
769 if let AuthCredential::OAuth {
770 refresh_token,
771 expires,
772 token_url,
773 client_id,
774 ..
775 } = cred
776 {
777 if matches!(
779 provider.as_str(),
780 "anthropic"
781 | "openai-codex"
782 | "google-gemini-cli"
783 | "google-antigravity"
784 | "kimi-for-coding"
785 ) {
786 continue;
787 }
788 if token_url.is_some() && client_id.is_some() {
791 continue;
792 }
793 if *expires <= proactive_deadline {
794 if let Some(config) = extension_configs.get(provider) {
795 refreshes.push((provider.clone(), refresh_token.clone(), config.clone()));
796 }
797 }
798 }
799 }
800
801 if !refreshes.is_empty() {
802 tracing::info!(
803 event = "pi.auth.extension_oauth_refresh.start",
804 count = refreshes.len(),
805 "Refreshing expired extension OAuth tokens"
806 );
807 }
808 let mut failed_providers: Vec<String> = Vec::new();
809 let mut needs_save = false;
810
811 for (provider, refresh_token, config) in refreshes {
812 let start = std::time::Instant::now();
813 match refresh_extension_oauth_token(client, &config, &refresh_token).await {
814 Ok(refreshed) => {
815 tracing::info!(
816 event = "pi.auth.extension_oauth_refresh.ok",
817 provider = %provider,
818 elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
819 "Extension OAuth token refreshed"
820 );
821 self.entries.insert(provider, refreshed);
822 needs_save = true;
823 }
824 Err(e) => {
825 tracing::warn!(
826 event = "pi.auth.extension_oauth_refresh.error",
827 provider = %provider,
828 error = %e,
829 elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
830 "Failed to refresh extension OAuth token; continuing with remaining providers"
831 );
832 failed_providers.push(format!("{provider} ({e})"));
833 }
834 }
835 }
836
837 if needs_save {
838 if let Err(e) = self.save_async().await {
839 tracing::warn!(
840 "Failed to save auth.json after refreshing extension OAuth tokens: {e}"
841 );
842 }
843 }
844
845 if failed_providers.is_empty() {
846 Ok(())
847 } else {
848 Err(Error::api(format!(
849 "Extension OAuth token refresh failed for: {}",
850 failed_providers.join(", ")
851 )))
852 }
853 }
854
855 pub fn prune_stale_credentials(&mut self, max_age_ms: i64) -> Vec<String> {
861 let now = chrono::Utc::now().timestamp_millis();
862 let cutoff = now - max_age_ms;
863 let mut pruned = Vec::new();
864
865 self.entries.retain(|provider, cred| {
866 if let AuthCredential::OAuth {
867 expires,
868 token_url,
869 client_id,
870 ..
871 } = cred
872 {
873 if *expires < cutoff && token_url.is_none() && client_id.is_none() {
876 tracing::info!(
877 event = "pi.auth.prune_stale",
878 provider = %provider,
879 expired_at = expires,
880 "Pruning stale OAuth credential"
881 );
882 pruned.push(provider.clone());
883 return false;
884 }
885 }
886 true
887 });
888
889 pruned
890 }
891}
892
893fn resolve_api_key_source(raw: &str) -> std::result::Result<Option<String>, String> {
906 resolve_api_key_source_with_resolvers(
907 raw,
908 |var| std::env::var(var).ok(),
909 run_api_key_source_command,
910 )
911}
912
913fn resolve_api_key_source_with_env<F>(
915 raw: &str,
916 env_lookup: F,
917) -> std::result::Result<Option<String>, String>
918where
919 F: FnOnce(&str) -> Option<String>,
920{
921 resolve_api_key_source_with_resolvers(raw, env_lookup, |_command| {
922 Err("API key command resolution is not available in this test helper".to_string())
923 })
924}
925
926fn resolve_api_key_source_with_resolvers<F, G>(
927 raw: &str,
928 env_lookup: F,
929 mut command_runner: G,
930) -> std::result::Result<Option<String>, String>
931where
932 F: FnOnce(&str) -> Option<String>,
933 G: FnMut(&str) -> std::result::Result<Option<String>, String>,
934{
935 if let Some(var_name) = raw.strip_prefix("$ENV:") {
936 if var_name.is_empty() {
937 return Err("$ENV: prefix requires a variable name (e.g. $ENV:MY_API_KEY)".to_string());
938 }
939 return match env_lookup(var_name) {
940 Some(value) if !value.trim().is_empty() => Ok(Some(value.trim().to_string())),
941 Some(_) => {
942 tracing::warn!(
943 event = "pi.auth.env_var_empty",
944 var = var_name,
945 "API key env var is set but empty"
946 );
947 Ok(None)
948 }
949 None => {
950 tracing::warn!(
951 event = "pi.auth.env_var_missing",
952 var = var_name,
953 "API key env var is not set"
954 );
955 Ok(None)
956 }
957 };
958 }
959
960 if let Some(command) = raw
961 .strip_prefix("$CMD:")
962 .or_else(|| raw.strip_prefix("$COMMAND:"))
963 {
964 let command = command.trim();
965 if command.is_empty() {
966 return Err(
967 "$CMD: prefix requires a shell command (e.g. $CMD:op read \"op://vault/item/field\" --no-newline)"
968 .to_string(),
969 );
970 }
971 return command_runner(command).map(|resolved| {
972 resolved.and_then(|value| {
973 let trimmed = value.trim();
974 (!trimmed.is_empty()).then(|| trimmed.to_string())
975 })
976 });
977 }
978
979 Ok(Some(raw.to_string()))
981}
982
983fn run_api_key_source_command(command: &str) -> std::result::Result<Option<String>, String> {
984 let output = build_api_key_command_shell(command)
985 .output()
986 .map_err(|e| format!("Failed to run API key command: {e}"))?;
987
988 if !output.status.success() {
989 let status = output.status.code().map_or_else(
990 || "terminated by signal".to_string(),
991 |code| format!("exit code {code}"),
992 );
993 return Err(format!("API key command failed ({status})"));
994 }
995
996 let stdout = String::from_utf8(output.stdout)
997 .map_err(|_| "API key command output is not valid UTF-8".to_string())?;
998 let trimmed = stdout.trim();
999 if trimmed.is_empty() {
1000 tracing::warn!(
1001 event = "pi.auth.key_command_empty",
1002 "API key command succeeded but produced empty output"
1003 );
1004 return Ok(None);
1005 }
1006
1007 Ok(Some(trimmed.to_string()))
1008}
1009
1010fn build_api_key_command_shell(command: &str) -> std::process::Command {
1011 #[cfg(windows)]
1012 {
1013 let mut shell = std::process::Command::new("cmd");
1014 shell.arg("/C").arg(command);
1015 shell
1016 }
1017
1018 #[cfg(not(windows))]
1019 {
1020 let shell_path = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
1021 .into_iter()
1022 .find(|path| Path::new(path).exists())
1023 .unwrap_or("sh");
1024 let mut shell = std::process::Command::new(shell_path);
1025 if shell_path.ends_with("bash") {
1026 shell.arg("-lc");
1027 } else {
1028 shell.arg("-c");
1029 }
1030 shell.arg(command);
1031 shell
1032 }
1033}
1034
1035fn api_key_from_credential(credential: &AuthCredential) -> Option<String> {
1036 match credential {
1037 AuthCredential::ApiKey { key } => match resolve_api_key_source(key) {
1038 Ok(resolved) => resolved,
1039 Err(err) => {
1040 tracing::warn!(
1041 event = "pi.auth.key_resolve_error",
1042 error = %err,
1043 "Failed to resolve API key source"
1044 );
1045 None
1046 }
1047 },
1048 AuthCredential::OAuth {
1049 access_token,
1050 expires,
1051 ..
1052 } => {
1053 let now = chrono::Utc::now().timestamp_millis();
1054 if *expires > now {
1055 Some(access_token.clone())
1056 } else {
1057 None
1058 }
1059 }
1060 AuthCredential::BearerToken { token } => Some(token.clone()),
1061 AuthCredential::AwsCredentials { access_key_id, .. } => Some(access_key_id.clone()),
1062 AuthCredential::ServiceKey { .. } | AuthCredential::Unknown(_) => None,
1063 }
1064}
1065
1066fn env_key_for_provider(provider: &str) -> Option<&'static str> {
1067 env_keys_for_provider(provider).first().copied()
1068}
1069
1070fn mark_anthropic_oauth_bearer_token(token: &str) -> String {
1071 format!("{ANTHROPIC_OAUTH_BEARER_MARKER}{token}")
1072}
1073
1074pub(crate) fn unmark_anthropic_oauth_bearer_token(token: &str) -> Option<&str> {
1075 token.strip_prefix(ANTHROPIC_OAUTH_BEARER_MARKER)
1076}
1077
1078fn env_keys_for_provider(provider: &str) -> &'static [&'static str] {
1079 provider_auth_env_keys(provider)
1080}
1081
1082fn resolve_external_provider_api_key(provider: &str) -> Option<String> {
1083 let canonical = canonical_provider_id(provider).unwrap_or(provider);
1084 match canonical {
1085 "anthropic" => read_external_claude_access_token()
1086 .map(|token| mark_anthropic_oauth_bearer_token(&token)),
1087 "openai" => read_external_codex_openai_api_key(),
1090 "openai-codex" => read_external_codex_access_token(),
1091 "google-gemini-cli" => {
1092 let project =
1093 google_project_id_from_env().or_else(google_project_id_from_gcloud_config);
1094 read_external_gemini_access_payload(project.as_deref())
1095 }
1096 "google-antigravity" => {
1097 let project =
1098 google_project_id_from_env().unwrap_or_else(google_antigravity_default_project_id);
1099 read_external_gemini_access_payload(Some(project.as_str()))
1100 }
1101 "kimi-for-coding" => read_external_kimi_code_access_token(),
1102 _ => None,
1103 }
1104}
1105
1106pub fn external_setup_source(provider: &str) -> Option<&'static str> {
1109 let canonical = canonical_provider_id(provider).unwrap_or(provider);
1110 match canonical {
1111 "anthropic" if read_external_claude_access_token().is_some() => {
1112 Some("Claude Code (~/.claude/.credentials.json)")
1113 }
1114 "openai" if read_external_codex_openai_api_key().is_some() => {
1115 Some("Codex (~/.codex/auth.json)")
1116 }
1117 "openai-codex" if read_external_codex_access_token().is_some() => {
1118 Some("Codex (~/.codex/auth.json)")
1119 }
1120 "google-gemini-cli" => {
1121 let project =
1122 google_project_id_from_env().or_else(google_project_id_from_gcloud_config);
1123 read_external_gemini_access_payload(project.as_deref())
1124 .is_some()
1125 .then_some("Gemini CLI (~/.gemini/oauth_creds.json)")
1126 }
1127 "google-antigravity" => {
1128 let project =
1129 google_project_id_from_env().unwrap_or_else(google_antigravity_default_project_id);
1130 if read_external_gemini_access_payload(Some(project.as_str())).is_some() {
1131 Some("Gemini CLI (~/.gemini/oauth_creds.json)")
1132 } else {
1133 None
1134 }
1135 }
1136 "kimi-for-coding" if read_external_kimi_code_access_token().is_some() => Some(
1137 "Kimi CLI (~/.kimi/credentials/kimi-code.json or $KIMI_SHARE_DIR/credentials/kimi-code.json)",
1138 ),
1139 _ => None,
1140 }
1141}
1142
1143fn read_external_json(path: &Path) -> Option<serde_json::Value> {
1144 let content = std::fs::read_to_string(path).ok()?;
1145 serde_json::from_str(&content).ok()
1146}
1147
1148fn read_external_claude_access_token() -> Option<String> {
1149 let path = home_dir()?.join(".claude").join(".credentials.json");
1150 let value = read_external_json(&path)?;
1151 let token = value
1152 .get("claudeAiOauth")
1153 .and_then(|oauth| oauth.get("accessToken"))
1154 .and_then(serde_json::Value::as_str)?
1155 .trim()
1156 .to_string();
1157 if token.is_empty() { None } else { Some(token) }
1158}
1159
1160fn read_external_codex_auth() -> Option<serde_json::Value> {
1161 let home = home_dir()?;
1162 let candidates = [
1163 home.join(".codex").join("auth.json"),
1164 home.join(".config").join("codex").join("auth.json"),
1165 ];
1166 for path in candidates {
1167 if let Some(value) = read_external_json(&path) {
1168 return Some(value);
1169 }
1170 }
1171 None
1172}
1173
1174fn read_external_codex_access_token() -> Option<String> {
1175 let value = read_external_codex_auth()?;
1176 codex_access_token_from_value(&value)
1177}
1178
1179fn read_external_codex_openai_api_key() -> Option<String> {
1180 let value = read_external_codex_auth()?;
1181 codex_openai_api_key_from_value(&value)
1182}
1183
1184fn codex_access_token_from_value(value: &serde_json::Value) -> Option<String> {
1185 let candidates = [
1186 value
1188 .get("tokens")
1189 .and_then(|tokens| tokens.get("access_token"))
1190 .and_then(serde_json::Value::as_str),
1191 value
1193 .get("tokens")
1194 .and_then(|tokens| tokens.get("accessToken"))
1195 .and_then(serde_json::Value::as_str),
1196 value
1198 .get("access_token")
1199 .and_then(serde_json::Value::as_str),
1200 value.get("accessToken").and_then(serde_json::Value::as_str),
1201 value.get("token").and_then(serde_json::Value::as_str),
1202 ];
1203
1204 candidates
1205 .into_iter()
1206 .flatten()
1207 .map(str::trim)
1208 .find(|token| !token.is_empty() && !token.starts_with("sk-"))
1209 .map(std::string::ToString::to_string)
1210}
1211
1212fn codex_openai_api_key_from_value(value: &serde_json::Value) -> Option<String> {
1213 let candidates = [
1214 value
1215 .get("OPENAI_API_KEY")
1216 .and_then(serde_json::Value::as_str),
1217 value
1218 .get("openai_api_key")
1219 .and_then(serde_json::Value::as_str),
1220 value
1221 .get("openaiApiKey")
1222 .and_then(serde_json::Value::as_str),
1223 value
1224 .get("env")
1225 .and_then(|env| env.get("OPENAI_API_KEY"))
1226 .and_then(serde_json::Value::as_str),
1227 value
1228 .get("env")
1229 .and_then(|env| env.get("openai_api_key"))
1230 .and_then(serde_json::Value::as_str),
1231 value
1232 .get("env")
1233 .and_then(|env| env.get("openaiApiKey"))
1234 .and_then(serde_json::Value::as_str),
1235 ];
1236
1237 candidates
1238 .into_iter()
1239 .flatten()
1240 .map(str::trim)
1241 .find(|key| !key.is_empty())
1242 .map(std::string::ToString::to_string)
1243}
1244
1245fn read_external_gemini_access_payload(project_id: Option<&str>) -> Option<String> {
1246 let home = home_dir()?;
1247 let candidates = [
1248 home.join(".gemini").join("oauth_creds.json"),
1249 home.join(".config").join("gemini").join("credentials.json"),
1250 ];
1251
1252 for path in candidates {
1253 let Some(value) = read_external_json(&path) else {
1254 continue;
1255 };
1256 let Some(token) = value
1257 .get("access_token")
1258 .and_then(serde_json::Value::as_str)
1259 .map(str::trim)
1260 .filter(|s| !s.is_empty())
1261 else {
1262 continue;
1263 };
1264
1265 let project = project_id
1266 .map(std::string::ToString::to_string)
1267 .or_else(|| {
1268 value
1269 .get("projectId")
1270 .or_else(|| value.get("project_id"))
1271 .and_then(serde_json::Value::as_str)
1272 .map(str::trim)
1273 .filter(|s| !s.is_empty())
1274 .map(std::string::ToString::to_string)
1275 })
1276 .or_else(google_project_id_from_gcloud_config)?;
1277 let project = project.trim();
1278 if project.is_empty() {
1279 continue;
1280 }
1281
1282 return Some(encode_project_scoped_access_token(token, project));
1283 }
1284
1285 None
1286}
1287
1288#[allow(clippy::cast_precision_loss)]
1289fn read_external_kimi_code_access_token() -> Option<String> {
1290 let share_dir = kimi_share_dir()?;
1291 read_external_kimi_code_access_token_from_share_dir(&share_dir)
1292}
1293
1294#[allow(clippy::cast_precision_loss)]
1295fn read_external_kimi_code_access_token_from_share_dir(share_dir: &Path) -> Option<String> {
1296 let path = share_dir.join("credentials").join("kimi-code.json");
1297 let value = read_external_json(&path)?;
1298
1299 let token = value
1300 .get("access_token")
1301 .and_then(serde_json::Value::as_str)
1302 .map(str::trim)
1303 .filter(|token| !token.is_empty())?;
1304
1305 let expires_at = value
1306 .get("expires_at")
1307 .and_then(|raw| raw.as_f64().or_else(|| raw.as_i64().map(|v| v as f64)));
1308 if let Some(expires_at) = expires_at {
1309 let now_seconds = chrono::Utc::now().timestamp() as f64;
1310 if expires_at <= now_seconds {
1311 return None;
1312 }
1313 }
1314
1315 Some(token.to_string())
1316}
1317
1318fn google_project_id_from_env() -> Option<String> {
1319 std::env::var("GOOGLE_CLOUD_PROJECT")
1320 .ok()
1321 .or_else(|| std::env::var("GOOGLE_CLOUD_PROJECT_ID").ok())
1322 .map(|value| value.trim().to_string())
1323 .filter(|value| !value.is_empty())
1324}
1325
1326fn gcloud_config_dir_with_env_lookup<F>(env_lookup: F) -> Option<PathBuf>
1327where
1328 F: Fn(&str) -> Option<String>,
1329{
1330 env_lookup("CLOUDSDK_CONFIG")
1331 .map(|value| value.trim().to_string())
1332 .filter(|value| !value.is_empty())
1333 .map(PathBuf::from)
1334 .or_else(|| {
1335 env_lookup("APPDATA")
1336 .map(|value| value.trim().to_string())
1337 .filter(|value| !value.is_empty())
1338 .map(|value| PathBuf::from(value).join("gcloud"))
1339 })
1340 .or_else(|| {
1341 env_lookup("XDG_CONFIG_HOME")
1342 .map(|value| value.trim().to_string())
1343 .filter(|value| !value.is_empty())
1344 .map(|value| PathBuf::from(value).join("gcloud"))
1345 })
1346 .or_else(|| {
1347 home_dir_with_env_lookup(env_lookup).map(|home| home.join(".config").join("gcloud"))
1348 })
1349}
1350
1351fn gcloud_active_config_name_with_env_lookup<F>(env_lookup: F) -> String
1352where
1353 F: Fn(&str) -> Option<String>,
1354{
1355 env_lookup("CLOUDSDK_ACTIVE_CONFIG_NAME")
1356 .map(|value| value.trim().to_string())
1357 .filter(|value| !value.is_empty())
1358 .unwrap_or_else(|| "default".to_string())
1359}
1360
1361fn google_project_id_from_gcloud_config_with_env_lookup<F>(env_lookup: F) -> Option<String>
1362where
1363 F: Fn(&str) -> Option<String>,
1364{
1365 let config_dir = gcloud_config_dir_with_env_lookup(&env_lookup)?;
1366 let config_name = gcloud_active_config_name_with_env_lookup(&env_lookup);
1367 let config_file = config_dir
1368 .join("configurations")
1369 .join(format!("config_{config_name}"));
1370 let Ok(content) = std::fs::read_to_string(config_file) else {
1371 return None;
1372 };
1373
1374 let mut section: Option<&str> = None;
1375 for raw_line in content.lines() {
1376 let line = raw_line.trim();
1377 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1378 continue;
1379 }
1380
1381 if let Some(rest) = line
1382 .strip_prefix('[')
1383 .and_then(|rest| rest.strip_suffix(']'))
1384 {
1385 section = Some(rest.trim());
1386 continue;
1387 }
1388
1389 if section != Some("core") {
1390 continue;
1391 }
1392
1393 let Some((key, value)) = line.split_once('=') else {
1394 continue;
1395 };
1396 if key.trim() != "project" {
1397 continue;
1398 }
1399 let project = value.trim();
1400 if project.is_empty() {
1401 continue;
1402 }
1403 return Some(project.to_string());
1404 }
1405
1406 None
1407}
1408
1409fn google_project_id_from_gcloud_config() -> Option<String> {
1410 google_project_id_from_gcloud_config_with_env_lookup(|key| std::env::var(key).ok())
1411}
1412
1413fn encode_project_scoped_access_token(token: &str, project_id: &str) -> String {
1414 serde_json::json!({
1415 "token": token,
1416 "projectId": project_id,
1417 })
1418 .to_string()
1419}
1420
1421fn decode_project_scoped_access_token(payload: &str) -> Option<(String, String)> {
1422 let value: serde_json::Value = serde_json::from_str(payload).ok()?;
1423 let token = value
1424 .get("token")
1425 .and_then(serde_json::Value::as_str)
1426 .map(str::trim)
1427 .filter(|s| !s.is_empty())?
1428 .to_string();
1429 let project_id = value
1430 .get("projectId")
1431 .or_else(|| value.get("project_id"))
1432 .and_then(serde_json::Value::as_str)
1433 .map(str::trim)
1434 .filter(|s| !s.is_empty())?
1435 .to_string();
1436 Some((token, project_id))
1437}
1438
1439#[derive(Debug, Clone, PartialEq, Eq)]
1443pub enum AwsResolvedCredentials {
1444 Sigv4 {
1446 access_key_id: String,
1447 secret_access_key: String,
1448 session_token: Option<String>,
1449 region: String,
1450 },
1451 Bearer { token: String, region: String },
1453}
1454
1455pub fn resolve_aws_credentials(auth: &AuthStorage) -> Option<AwsResolvedCredentials> {
1466 resolve_aws_credentials_with_env(auth, |var| std::env::var(var).ok())
1467}
1468
1469fn resolve_aws_credentials_with_env<F>(
1470 auth: &AuthStorage,
1471 mut env: F,
1472) -> Option<AwsResolvedCredentials>
1473where
1474 F: FnMut(&str) -> Option<String>,
1475{
1476 let env_region = env("AWS_REGION")
1477 .or_else(|| env("AWS_DEFAULT_REGION"))
1478 .map(|value| value.trim().to_string())
1479 .filter(|value| !value.is_empty());
1480 let region = env_region
1481 .clone()
1482 .unwrap_or_else(|| "us-east-1".to_string());
1483
1484 if let Some(token) = env("AWS_BEARER_TOKEN_BEDROCK") {
1486 let token = token.trim().to_string();
1487 if !token.is_empty() {
1488 return Some(AwsResolvedCredentials::Bearer { token, region });
1489 }
1490 }
1491
1492 if let Some(access_key) = env("AWS_ACCESS_KEY_ID") {
1494 let access_key = access_key.trim().to_string();
1495 if !access_key.is_empty() {
1496 if let Some(secret_key) = env("AWS_SECRET_ACCESS_KEY") {
1497 let secret_key = secret_key.trim().to_string();
1498 if !secret_key.is_empty() {
1499 let session_token = env("AWS_SESSION_TOKEN")
1500 .map(|s| s.trim().to_string())
1501 .filter(|s| !s.is_empty());
1502 return Some(AwsResolvedCredentials::Sigv4 {
1503 access_key_id: access_key,
1504 secret_access_key: secret_key,
1505 session_token,
1506 region,
1507 });
1508 }
1509 }
1510 }
1511 }
1512
1513 if let Some(profile) = env("AWS_PROFILE").or_else(|| env("AWS_DEFAULT_PROFILE")) {
1515 let profile = profile.trim().to_string();
1516 if !profile.is_empty() {
1517 if let Some(resolved) = resolve_aws_profile_credentials_with_env(
1518 &profile,
1519 env_region.as_deref(),
1520 ®ion,
1521 &mut env,
1522 ) {
1523 return Some(resolved);
1524 }
1525 tracing::warn!(
1526 event = "pi.auth.aws_profile_missing",
1527 profile = %profile,
1528 "AWS_PROFILE set but no credentials found in ~/.aws/credentials"
1529 );
1530 }
1531 }
1532
1533 let provider = "amazon-bedrock";
1535 match auth.credential_for_provider(provider) {
1536 Some(AuthCredential::AwsCredentials {
1537 access_key_id,
1538 secret_access_key,
1539 session_token,
1540 region: stored_region,
1541 }) => Some(AwsResolvedCredentials::Sigv4 {
1542 access_key_id: access_key_id.clone(),
1543 secret_access_key: secret_access_key.clone(),
1544 session_token: session_token.clone(),
1545 region: stored_region.clone().unwrap_or(region),
1546 }),
1547 Some(AuthCredential::BearerToken { token }) => Some(AwsResolvedCredentials::Bearer {
1548 token: token.clone(),
1549 region,
1550 }),
1551 Some(AuthCredential::ApiKey { key }) => {
1552 match resolve_api_key_source(key) {
1555 Ok(Some(resolved)) => Some(AwsResolvedCredentials::Bearer {
1556 token: resolved,
1557 region,
1558 }),
1559 Ok(None) => None,
1560 Err(err) => {
1561 tracing::warn!(
1562 event = "pi.auth.key_resolve_error",
1563 error = %err,
1564 "Failed to resolve API key source for bedrock"
1565 );
1566 None
1567 }
1568 }
1569 }
1570 _ => None,
1571 }
1572}
1573
1574fn aws_home_dir_from_env<F>(env: &mut F) -> Option<PathBuf>
1575where
1576 F: FnMut(&str) -> Option<String>,
1577{
1578 env("HOME")
1579 .map(|value| value.trim().to_string())
1580 .filter(|value| !value.is_empty())
1581 .map(PathBuf::from)
1582 .or_else(|| {
1583 env("USERPROFILE")
1584 .map(|value| value.trim().to_string())
1585 .filter(|value| !value.is_empty())
1586 .map(PathBuf::from)
1587 })
1588 .or_else(|| {
1589 let drive = env("HOMEDRIVE")
1590 .map(|value| value.trim().to_string())
1591 .filter(|value| !value.is_empty())?;
1592 let path = env("HOMEPATH")
1593 .map(|value| value.trim().to_string())
1594 .filter(|value| !value.is_empty())?;
1595 if path.starts_with('\\') || path.starts_with('/') {
1596 Some(PathBuf::from(format!("{drive}{path}")))
1597 } else {
1598 let mut combined = PathBuf::from(drive);
1599 combined.push(path);
1600 Some(combined)
1601 }
1602 })
1603}
1604
1605fn aws_credentials_paths_from_env<F>(env: &mut F) -> Option<(PathBuf, PathBuf)>
1606where
1607 F: FnMut(&str) -> Option<String>,
1608{
1609 let credentials_path = env("AWS_SHARED_CREDENTIALS_FILE")
1610 .map(|value| value.trim().to_string())
1611 .filter(|value| !value.is_empty())
1612 .map(PathBuf::from);
1613 let config_path = env("AWS_CONFIG_FILE")
1614 .map(|value| value.trim().to_string())
1615 .filter(|value| !value.is_empty())
1616 .map(PathBuf::from);
1617
1618 if let (Some(credentials_path), Some(config_path)) =
1619 (credentials_path.clone(), config_path.clone())
1620 {
1621 return Some((credentials_path, config_path));
1622 }
1623
1624 let home = aws_home_dir_from_env(env);
1625 let credentials_path = if let Some(path) = credentials_path {
1626 path
1627 } else {
1628 let home = home.as_ref()?;
1629 home.join(".aws").join("credentials")
1630 };
1631 let config_path = config_path.unwrap_or_else(|| {
1632 home.map_or_else(
1633 || credentials_path.clone(),
1634 |home| home.join(".aws").join("config"),
1635 )
1636 });
1637 Some((credentials_path, config_path))
1638}
1639
1640fn parse_aws_ini(contents: &str) -> HashMap<String, HashMap<String, String>> {
1641 let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
1642 let mut current: Option<String> = None;
1643
1644 for raw_line in contents.lines() {
1645 let line = raw_line.trim();
1646 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1647 continue;
1648 }
1649 if line.starts_with('[') && line.ends_with(']') {
1650 let name = line[1..line.len() - 1].trim().to_ascii_lowercase();
1651 if !name.is_empty() {
1652 current = Some(name);
1653 }
1654 continue;
1655 }
1656 let Some(section) = current.clone() else {
1657 continue;
1658 };
1659 let mut splitter = line.splitn(2, ['=', ':']);
1660 let key = splitter.next().unwrap_or("").trim().to_ascii_lowercase();
1661 let value = splitter.next().unwrap_or("").trim().to_string();
1662 if key.is_empty() || value.is_empty() {
1663 continue;
1664 }
1665 sections.entry(section).or_default().insert(key, value);
1666 }
1667
1668 sections
1669}
1670
1671fn resolve_aws_profile_credentials_with_env<F>(
1672 profile: &str,
1673 region_override: Option<&str>,
1674 region_default: &str,
1675 env: &mut F,
1676) -> Option<AwsResolvedCredentials>
1677where
1678 F: FnMut(&str) -> Option<String>,
1679{
1680 let (credentials_path, config_path) = aws_credentials_paths_from_env(env)?;
1681 let credentials_text = std::fs::read_to_string(&credentials_path).ok()?;
1682 let credentials = parse_aws_ini(&credentials_text);
1683 let profile_key = profile.trim().to_ascii_lowercase();
1684 let section = credentials.get(&profile_key)?;
1685
1686 let access_key_id = section.get("aws_access_key_id")?.trim().to_string();
1687 let secret_access_key = section.get("aws_secret_access_key")?.trim().to_string();
1688 if access_key_id.is_empty() || secret_access_key.is_empty() {
1689 return None;
1690 }
1691
1692 let session_token = section
1693 .get("aws_session_token")
1694 .or_else(|| section.get("aws_security_token"))
1695 .map(|value| value.trim().to_string())
1696 .filter(|value| !value.is_empty());
1697
1698 let mut region = region_override.map_or_else(|| region_default.to_string(), str::to_string);
1699
1700 let allow_config_region = region_override.is_none();
1701 if allow_config_region {
1702 if let Some(value) = section.get("region") {
1703 let trimmed = value.trim();
1704 if !trimmed.is_empty() {
1705 region = trimmed.to_string();
1706 }
1707 }
1708 }
1709
1710 if allow_config_region {
1711 if let Ok(config_text) = std::fs::read_to_string(&config_path) {
1712 let config = parse_aws_ini(&config_text);
1713 let mut candidates = Vec::new();
1714 if profile_key == "default" {
1715 candidates.push("default".to_string());
1716 candidates.push("profile default".to_string());
1717 } else {
1718 candidates.push(format!("profile {profile_key}"));
1719 candidates.push(profile_key.clone());
1720 }
1721 for name in candidates {
1722 if let Some(section) = config.get(&name) {
1723 if let Some(value) = section.get("region") {
1724 let trimmed = value.trim();
1725 if !trimmed.is_empty() {
1726 region = trimmed.to_string();
1727 break;
1728 }
1729 }
1730 }
1731 }
1732 }
1733 }
1734
1735 Some(AwsResolvedCredentials::Sigv4 {
1736 access_key_id,
1737 secret_access_key,
1738 session_token,
1739 region,
1740 })
1741}
1742
1743#[derive(Debug, Clone, PartialEq, Eq)]
1747pub struct SapResolvedCredentials {
1748 pub client_id: String,
1749 pub client_secret: String,
1750 pub token_url: String,
1751 pub service_url: String,
1752}
1753
1754pub fn resolve_sap_credentials(auth: &AuthStorage) -> Option<SapResolvedCredentials> {
1762 resolve_sap_credentials_with_env(auth, |var| std::env::var(var).ok())
1763}
1764
1765fn resolve_sap_credentials_with_env<F>(
1766 auth: &AuthStorage,
1767 mut env: F,
1768) -> Option<SapResolvedCredentials>
1769where
1770 F: FnMut(&str) -> Option<String>,
1771{
1772 if let Some(key_json) = env("AICORE_SERVICE_KEY") {
1774 if let Some(creds) = parse_sap_service_key_json(&key_json) {
1775 return Some(creds);
1776 }
1777 }
1778
1779 let client_id = env("SAP_AI_CORE_CLIENT_ID");
1781 let client_secret = env("SAP_AI_CORE_CLIENT_SECRET");
1782 let token_url = env("SAP_AI_CORE_TOKEN_URL");
1783 let service_url = env("SAP_AI_CORE_SERVICE_URL");
1784
1785 if let (Some(id), Some(secret), Some(turl), Some(surl)) =
1786 (client_id, client_secret, token_url, service_url)
1787 {
1788 let id = id.trim().to_string();
1789 let secret = secret.trim().to_string();
1790 let turl = turl.trim().to_string();
1791 let surl = surl.trim().to_string();
1792 if !id.is_empty() && !secret.is_empty() && !turl.is_empty() && !surl.is_empty() {
1793 return Some(SapResolvedCredentials {
1794 client_id: id,
1795 client_secret: secret,
1796 token_url: turl,
1797 service_url: surl,
1798 });
1799 }
1800 }
1801
1802 let provider = "sap-ai-core";
1804 if let Some(AuthCredential::ServiceKey {
1805 client_id,
1806 client_secret,
1807 token_url,
1808 service_url,
1809 }) = auth.credential_for_provider(provider)
1810 {
1811 if let (Some(id), Some(secret), Some(turl), Some(surl)) = (
1812 client_id.as_ref(),
1813 client_secret.as_ref(),
1814 token_url.as_ref(),
1815 service_url.as_ref(),
1816 ) {
1817 if !id.is_empty() && !secret.is_empty() && !turl.is_empty() && !surl.is_empty() {
1818 return Some(SapResolvedCredentials {
1819 client_id: id.clone(),
1820 client_secret: secret.clone(),
1821 token_url: turl.clone(),
1822 service_url: surl.clone(),
1823 });
1824 }
1825 }
1826 }
1827
1828 None
1829}
1830
1831fn parse_sap_service_key_json(json_str: &str) -> Option<SapResolvedCredentials> {
1833 let v: serde_json::Value = serde_json::from_str(json_str).ok()?;
1834 let obj = v.as_object()?;
1835
1836 let client_id = obj
1839 .get("clientid")
1840 .or_else(|| obj.get("client_id"))
1841 .and_then(|v| v.as_str())
1842 .filter(|s| !s.is_empty())?;
1843 let client_secret = obj
1844 .get("clientsecret")
1845 .or_else(|| obj.get("client_secret"))
1846 .and_then(|v| v.as_str())
1847 .filter(|s| !s.is_empty())?;
1848 let token_url = obj
1849 .get("url")
1850 .or_else(|| obj.get("token_url"))
1851 .and_then(|v| v.as_str())
1852 .filter(|s| !s.is_empty())?;
1853 let service_url = obj
1854 .get("serviceurls")
1855 .and_then(|v| v.get("AI_API_URL"))
1856 .and_then(|v| v.as_str())
1857 .or_else(|| obj.get("service_url").and_then(|v| v.as_str()))
1858 .filter(|s| !s.is_empty())?;
1859
1860 Some(SapResolvedCredentials {
1861 client_id: client_id.to_string(),
1862 client_secret: client_secret.to_string(),
1863 token_url: token_url.to_string(),
1864 service_url: service_url.to_string(),
1865 })
1866}
1867
1868#[derive(Debug, Deserialize)]
1869struct SapTokenExchangeResponse {
1870 access_token: String,
1871}
1872
1873pub async fn exchange_sap_access_token(auth: &AuthStorage) -> Result<Option<String>> {
1877 let Some(creds) = resolve_sap_credentials(auth) else {
1878 return Ok(None);
1879 };
1880
1881 let client = crate::http::client::Client::new();
1882 let token = exchange_sap_access_token_with_client(&client, &creds).await?;
1883 Ok(Some(token))
1884}
1885
1886async fn exchange_sap_access_token_with_client(
1887 client: &crate::http::client::Client,
1888 creds: &SapResolvedCredentials,
1889) -> Result<String> {
1890 let form_body = format!(
1891 "grant_type=client_credentials&client_id={}&client_secret={}",
1892 percent_encode_component(&creds.client_id),
1893 percent_encode_component(&creds.client_secret),
1894 );
1895
1896 let request = client
1897 .post(&creds.token_url)
1898 .header("Content-Type", "application/x-www-form-urlencoded")
1899 .header("Accept", "application/json")
1900 .body(form_body.into_bytes());
1901
1902 let response = Box::pin(request.send())
1903 .await
1904 .map_err(|e| Error::auth(format!("SAP AI Core token exchange failed: {e}")))?;
1905
1906 let status = response.status();
1907 let text = response
1908 .text()
1909 .await
1910 .unwrap_or_else(|_| "<failed to read body>".to_string());
1911 let redacted_text = redact_known_secrets(
1912 &text,
1913 &[creds.client_id.as_str(), creds.client_secret.as_str()],
1914 );
1915
1916 if !(200..300).contains(&status) {
1917 return Err(Error::auth(format!(
1918 "SAP AI Core token exchange failed (HTTP {status}): {redacted_text}"
1919 )));
1920 }
1921
1922 let response: SapTokenExchangeResponse = serde_json::from_str(&text)
1923 .map_err(|e| Error::auth(format!("SAP AI Core token response was invalid JSON: {e}")))?;
1924 let access_token = response.access_token.trim();
1925 if access_token.is_empty() {
1926 return Err(Error::auth(
1927 "SAP AI Core token exchange returned an empty access_token".to_string(),
1928 ));
1929 }
1930
1931 Ok(access_token.to_string())
1932}
1933
1934fn redact_known_secrets(text: &str, secrets: &[&str]) -> String {
1935 let mut redacted = text.to_string();
1936 for secret in secrets {
1937 let trimmed = secret.trim();
1938 if !trimmed.is_empty() {
1939 redacted = redacted.replace(trimmed, "[REDACTED]");
1940 }
1941 }
1942
1943 redact_sensitive_json_fields(&redacted)
1944}
1945
1946fn redact_sensitive_json_fields(text: &str) -> String {
1947 let Ok(mut json) = serde_json::from_str::<serde_json::Value>(text) else {
1948 return text.to_string();
1949 };
1950 redact_sensitive_json_value(&mut json);
1951 serde_json::to_string(&json).unwrap_or_else(|_| text.to_string())
1952}
1953
1954fn redact_sensitive_json_value(value: &mut serde_json::Value) {
1955 match value {
1956 serde_json::Value::Object(map) => {
1957 for (key, nested) in map {
1958 if is_sensitive_json_key(key) {
1959 *nested = serde_json::Value::String("[REDACTED]".to_string());
1960 } else {
1961 redact_sensitive_json_value(nested);
1962 }
1963 }
1964 }
1965 serde_json::Value::Array(items) => {
1966 for item in items {
1967 redact_sensitive_json_value(item);
1968 }
1969 }
1970 serde_json::Value::Null
1971 | serde_json::Value::Bool(_)
1972 | serde_json::Value::Number(_)
1973 | serde_json::Value::String(_) => {}
1974 }
1975}
1976
1977fn is_sensitive_json_key(key: &str) -> bool {
1978 let normalized: String = key
1979 .chars()
1980 .filter(char::is_ascii_alphanumeric)
1981 .map(|ch| ch.to_ascii_lowercase())
1982 .collect();
1983
1984 matches!(
1985 normalized.as_str(),
1986 "token"
1987 | "accesstoken"
1988 | "refreshtoken"
1989 | "idtoken"
1990 | "apikey"
1991 | "authorization"
1992 | "credential"
1993 | "secret"
1994 | "clientsecret"
1995 | "password"
1996 ) || normalized.ends_with("token")
1997 || normalized.ends_with("secret")
1998 || normalized.ends_with("apikey")
1999 || normalized.contains("authorization")
2000}
2001
2002#[derive(Debug)]
2003pub struct OAuthStartInfo {
2004 pub provider: String,
2005 pub url: String,
2006 pub verifier: String,
2007 pub instructions: Option<String>,
2008 pub redirect_uri: Option<String>,
2011 pub callback_server: Option<OAuthCallbackServer>,
2018}
2019
2020pub struct OAuthCallbackServer {
2029 pub rx: std::sync::mpsc::Receiver<String>,
2031 pub port: u16,
2033 _handle: std::thread::JoinHandle<()>,
2036}
2037
2038impl std::fmt::Debug for OAuthCallbackServer {
2039 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2040 f.debug_struct("OAuthCallbackServer")
2041 .field("port", &self.port)
2042 .finish_non_exhaustive()
2043 }
2044}
2045
2046pub fn start_oauth_callback_server(redirect_uri: &str) -> Result<OAuthCallbackServer> {
2055 let port = parse_port_from_uri(redirect_uri).ok_or_else(|| {
2057 Error::auth(format!(
2058 "Cannot parse port from OAuth redirect URI: {redirect_uri}"
2059 ))
2060 })?;
2061
2062 let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}")).map_err(|e| {
2063 Error::auth(format!(
2064 "Failed to bind OAuth callback server on port {port}: {e}"
2065 ))
2066 })?;
2067
2068 listener
2072 .set_nonblocking(false)
2073 .map_err(|e| Error::auth(format!("Failed to configure callback listener: {e}")))?;
2074
2075 let (tx, rx) = std::sync::mpsc::channel::<String>();
2076
2077 let handle = std::thread::spawn(move || {
2078 let Ok((mut stream, _addr)) = listener.accept() else {
2080 return;
2081 };
2082 let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
2083
2084 let mut buf = [0u8; 4096];
2086 let Ok(n) = stream.read(&mut buf) else {
2087 return;
2088 };
2089
2090 let request = String::from_utf8_lossy(&buf[..n]);
2091 let request_path = request
2092 .lines()
2093 .next()
2094 .and_then(|line| {
2095 let parts: Vec<&str> = line.split_whitespace().collect();
2097 if parts.len() >= 2 {
2098 Some(parts[1].to_string())
2099 } else {
2100 None
2101 }
2102 })
2103 .unwrap_or_default();
2104
2105 let html = r#"<!DOCTYPE html>
2107<html><head><title>Pi Agent — OAuth Complete</title></head>
2108<body style="font-family:system-ui,sans-serif;text-align:center;padding:60px 20px;background:#f8f9fa">
2109<h1 style="color:#2d7d46">✓ Authorization successful</h1>
2110<p>You can close this browser tab and return to Pi Agent.</p>
2111</body></html>"#;
2112
2113 let response = format!(
2114 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
2115 html.len()
2116 );
2117 if let Err(e) = stream.write_all(response.as_bytes()) {
2118 tracing::debug!("Failed to write HTTP response: {e}");
2119 }
2120 if let Err(e) = stream.flush() {
2121 tracing::debug!("Failed to flush HTTP response: {e}");
2122 }
2123
2124 if tx.send(request_path).is_err() {
2126 tracing::debug!(
2127 "OAuth callback receiver was dropped before callback URL could be delivered"
2128 );
2129 }
2130 });
2131
2132 Ok(OAuthCallbackServer {
2133 rx,
2134 port,
2135 _handle: handle,
2136 })
2137}
2138
2139fn parse_port_from_uri(uri: &str) -> Option<u16> {
2144 let without_scheme = uri
2146 .strip_prefix("http://")
2147 .or_else(|| uri.strip_prefix("https://"))?;
2148 let host_port = without_scheme.split('/').next()?;
2150 let port_str = host_port.rsplit(':').next()?;
2152 port_str.parse::<u16>().ok()
2153}
2154
2155pub fn redirect_uri_needs_callback_server(redirect_uri: &str) -> bool {
2158 let lower = redirect_uri.to_lowercase();
2159 lower.starts_with("http://localhost:") || lower.starts_with("http://127.0.0.1:")
2160}
2161
2162pub fn start_oauth_callback_server_random_port() -> Result<(OAuthCallbackServer, String)> {
2170 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| {
2171 Error::auth(format!(
2172 "Failed to bind OAuth callback server on random port: {e}"
2173 ))
2174 })?;
2175
2176 let port = listener
2177 .local_addr()
2178 .map_err(|e| {
2179 Error::auth(format!(
2180 "Failed to get local address of OAuth callback listener: {e}"
2181 ))
2182 })?
2183 .port();
2184
2185 let redirect_uri = format!("http://localhost:{port}/callback");
2186
2187 listener
2188 .set_nonblocking(false)
2189 .map_err(|e| Error::auth(format!("Failed to configure callback listener: {e}")))?;
2190
2191 let (tx, rx) = std::sync::mpsc::channel::<String>();
2192
2193 let handle = std::thread::spawn(move || {
2194 let Ok((mut stream, _addr)) = listener.accept() else {
2195 return;
2196 };
2197 let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
2198
2199 let mut buf = [0u8; 4096];
2200 let Ok(n) = stream.read(&mut buf) else {
2201 return;
2202 };
2203
2204 let request = String::from_utf8_lossy(&buf[..n]);
2205 let request_path = request
2206 .lines()
2207 .next()
2208 .and_then(|line| {
2209 let parts: Vec<&str> = line.split_whitespace().collect();
2210 if parts.len() >= 2 {
2211 Some(parts[1].to_string())
2212 } else {
2213 None
2214 }
2215 })
2216 .unwrap_or_default();
2217
2218 let html = r#"<!DOCTYPE html>
2219<html><head><title>Pi Agent — OAuth Complete</title></head>
2220<body style="font-family:system-ui,sans-serif;text-align:center;padding:60px 20px;background:#f8f9fa">
2221<h1 style="color:#2d7d46">✓ Authorization successful</h1>
2222<p>You can close this browser tab and return to Pi Agent.</p>
2223</body></html>"#;
2224
2225 let response = format!(
2226 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
2227 html.len()
2228 );
2229 if let Err(e) = stream.write_all(response.as_bytes()) {
2230 tracing::debug!("Failed to write HTTP response: {e}");
2231 }
2232 if let Err(e) = stream.flush() {
2233 tracing::debug!("Failed to flush HTTP response: {e}");
2234 }
2235
2236 if tx.send(request_path).is_err() {
2237 tracing::debug!(
2238 "OAuth callback receiver was dropped before callback URL could be delivered"
2239 );
2240 }
2241 });
2242
2243 Ok((
2244 OAuthCallbackServer {
2245 rx,
2246 port,
2247 _handle: handle,
2248 },
2249 redirect_uri,
2250 ))
2251}
2252
2253#[derive(Debug, Clone, Serialize, Deserialize)]
2257pub struct DeviceCodeResponse {
2258 pub device_code: String,
2259 pub user_code: String,
2260 pub verification_uri: String,
2261 #[serde(default)]
2262 pub verification_uri_complete: Option<String>,
2263 pub expires_in: u64,
2264 #[serde(default = "default_device_interval")]
2265 pub interval: u64,
2266}
2267
2268const fn default_device_interval() -> u64 {
2269 5
2270}
2271
2272#[derive(Debug)]
2274pub enum DeviceFlowPollResult {
2275 Pending,
2277 SlowDown,
2279 Success(AuthCredential),
2281 Expired,
2283 AccessDenied,
2285 Error(String),
2287}
2288
2289#[derive(Debug, Clone)]
2296pub struct CopilotOAuthConfig {
2297 pub client_id: String,
2298 pub github_base_url: String,
2299 pub scopes: String,
2300}
2301
2302impl Default for CopilotOAuthConfig {
2303 fn default() -> Self {
2304 Self {
2305 client_id: String::new(),
2306 github_base_url: "https://github.com".to_string(),
2307 scopes: GITHUB_COPILOT_SCOPES.to_string(),
2308 }
2309 }
2310}
2311
2312#[derive(Debug, Clone)]
2317pub struct GitLabOAuthConfig {
2318 pub client_id: String,
2319 pub base_url: String,
2320 pub scopes: String,
2321 pub redirect_uri: Option<String>,
2322}
2323
2324impl Default for GitLabOAuthConfig {
2325 fn default() -> Self {
2326 Self {
2327 client_id: String::new(),
2328 base_url: GITLAB_DEFAULT_BASE_URL.to_string(),
2329 scopes: GITLAB_DEFAULT_SCOPES.to_string(),
2330 redirect_uri: None,
2331 }
2332 }
2333}
2334
2335fn percent_encode_component(value: &str) -> String {
2336 let mut out = String::with_capacity(value.len());
2337 for b in value.as_bytes() {
2338 match *b {
2339 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
2340 out.push(*b as char);
2341 }
2342 b' ' => out.push_str("%20"),
2343 other => {
2344 let _ = write!(out, "%{other:02X}");
2345 }
2346 }
2347 }
2348 out
2349}
2350
2351fn percent_decode_component(value: &str) -> Option<String> {
2352 if !value.as_bytes().contains(&b'%') && !value.as_bytes().contains(&b'+') {
2353 return Some(value.to_string());
2354 }
2355
2356 let mut out = Vec::with_capacity(value.len());
2357 let mut bytes = value.as_bytes().iter().copied();
2358 while let Some(b) = bytes.next() {
2359 match b {
2360 b'+' => out.push(b' '),
2361 b'%' => {
2362 let hi = bytes.next()?;
2363 let lo = bytes.next()?;
2364 let hex = [hi, lo];
2365 let hex = std::str::from_utf8(&hex).ok()?;
2366 let decoded = u8::from_str_radix(hex, 16).ok()?;
2367 out.push(decoded);
2368 }
2369 other => out.push(other),
2370 }
2371 }
2372
2373 String::from_utf8(out).ok()
2374}
2375
2376fn parse_query_pairs(query: &str) -> Vec<(String, String)> {
2377 query
2378 .split('&')
2379 .filter(|part| !part.trim().is_empty())
2380 .filter_map(|part| {
2381 let (k, v) = part.split_once('=').unwrap_or((part, ""));
2382 let key = percent_decode_component(k.trim())?;
2383 let value = percent_decode_component(v.trim())?;
2384 Some((key, value))
2385 })
2386 .collect()
2387}
2388
2389fn build_url_with_query(base: &str, params: &[(&str, &str)]) -> String {
2390 let mut url = String::with_capacity(base.len() + 128);
2391 url.push_str(base);
2392 url.push('?');
2393
2394 for (idx, (k, v)) in params.iter().enumerate() {
2395 if idx > 0 {
2396 url.push('&');
2397 }
2398 url.push_str(&percent_encode_component(k));
2399 url.push('=');
2400 url.push_str(&percent_encode_component(v));
2401 }
2402
2403 url
2404}
2405
2406fn kimi_code_oauth_host_with_env_lookup<F>(env_lookup: F) -> String
2407where
2408 F: Fn(&str) -> Option<String>,
2409{
2410 KIMI_CODE_OAUTH_HOST_ENV_KEYS
2411 .iter()
2412 .find_map(|key| {
2413 env_lookup(key)
2414 .map(|value| value.trim().to_string())
2415 .filter(|value| !value.is_empty())
2416 })
2417 .unwrap_or_else(|| KIMI_CODE_OAUTH_DEFAULT_HOST.to_string())
2418}
2419
2420fn kimi_code_oauth_host() -> String {
2421 kimi_code_oauth_host_with_env_lookup(|key| std::env::var(key).ok())
2422}
2423
2424fn kimi_code_endpoint_for_host(host: &str, path: &str) -> String {
2425 format!("{}{}", trim_trailing_slash(host), path)
2426}
2427
2428fn kimi_code_token_endpoint() -> String {
2429 kimi_code_endpoint_for_host(&kimi_code_oauth_host(), KIMI_CODE_TOKEN_PATH)
2430}
2431
2432fn home_dir_with_env_lookup<F>(env_lookup: F) -> Option<PathBuf>
2433where
2434 F: Fn(&str) -> Option<String>,
2435{
2436 env_lookup("HOME")
2437 .map(|value| value.trim().to_string())
2438 .filter(|value| !value.is_empty())
2439 .map(PathBuf::from)
2440 .or_else(|| {
2441 env_lookup("USERPROFILE")
2442 .map(|value| value.trim().to_string())
2443 .filter(|value| !value.is_empty())
2444 .map(PathBuf::from)
2445 })
2446 .or_else(|| {
2447 let drive = env_lookup("HOMEDRIVE")
2448 .map(|value| value.trim().to_string())
2449 .filter(|value| !value.is_empty())?;
2450 let path = env_lookup("HOMEPATH")
2451 .map(|value| value.trim().to_string())
2452 .filter(|value| !value.is_empty())?;
2453 if path.starts_with('\\') || path.starts_with('/') {
2454 Some(PathBuf::from(format!("{drive}{path}")))
2455 } else {
2456 let mut combined = PathBuf::from(drive);
2457 combined.push(path);
2458 Some(combined)
2459 }
2460 })
2461}
2462
2463fn home_dir() -> Option<PathBuf> {
2464 home_dir_with_env_lookup(|key| std::env::var(key).ok())
2465}
2466
2467fn kimi_share_dir_with_env_lookup<F>(env_lookup: F) -> Option<PathBuf>
2468where
2469 F: Fn(&str) -> Option<String>,
2470{
2471 env_lookup(KIMI_SHARE_DIR_ENV_KEY)
2472 .map(|value| value.trim().to_string())
2473 .filter(|value| !value.is_empty())
2474 .map(PathBuf::from)
2475 .or_else(|| home_dir_with_env_lookup(env_lookup).map(|home| home.join(".kimi")))
2476}
2477
2478fn kimi_share_dir() -> Option<PathBuf> {
2479 kimi_share_dir_with_env_lookup(|key| std::env::var(key).ok())
2480}
2481
2482fn sanitize_ascii_header_value(value: &str, fallback: &str) -> String {
2483 if value.is_ascii() && !value.trim().is_empty() {
2484 return value.to_string();
2485 }
2486
2487 let sanitized = value
2488 .chars()
2489 .filter(char::is_ascii)
2490 .collect::<String>()
2491 .trim()
2492 .to_string();
2493 if sanitized.is_empty() {
2494 fallback.to_string()
2495 } else {
2496 sanitized
2497 }
2498}
2499
2500fn kimi_device_id_paths() -> Option<(PathBuf, PathBuf)> {
2501 let primary = kimi_share_dir()?.join("device_id");
2502 let legacy = home_dir().map_or_else(
2503 || primary.clone(),
2504 |home| home.join(".pi").join("agent").join("kimi-device-id"),
2505 );
2506 Some((primary, legacy))
2507}
2508
2509fn kimi_device_id() -> String {
2510 let generated = uuid::Uuid::new_v4().simple().to_string();
2511 let Some((primary, legacy)) = kimi_device_id_paths() else {
2512 return generated;
2513 };
2514
2515 for path in [&primary, &legacy] {
2516 if let Ok(existing) = fs::read_to_string(path) {
2517 let existing = existing.trim();
2518 if !existing.is_empty() {
2519 return existing.to_string();
2520 }
2521 }
2522 }
2523
2524 if let Some(parent) = primary.parent() {
2525 if let Err(err) = fs::create_dir_all(parent) {
2526 tracing::debug!(
2527 path = ?parent,
2528 error = %err,
2529 "Failed to create directory for generated credential file"
2530 );
2531 }
2532 }
2533
2534 let mut options = std::fs::OpenOptions::new();
2535 options.write(true).create_new(true);
2536
2537 #[cfg(unix)]
2538 {
2539 use std::os::unix::fs::OpenOptionsExt;
2540 options.mode(0o600);
2541 }
2542
2543 if let Ok(mut file) = options.open(&primary) {
2544 if let Err(err) = file.write_all(generated.as_bytes()) {
2545 tracing::debug!(
2546 path = ?primary,
2547 error = %err,
2548 "Failed to write generated credential data to file"
2549 );
2550 }
2551 }
2552
2553 generated
2554}
2555
2556fn kimi_common_headers() -> Vec<(String, String)> {
2557 let device_name = std::env::var("HOSTNAME")
2558 .ok()
2559 .or_else(|| std::env::var("COMPUTERNAME").ok())
2560 .unwrap_or_else(|| "unknown".to_string());
2561 let device_model = format!("{} {}", std::env::consts::OS, std::env::consts::ARCH);
2562 let os_version = std::env::consts::OS.to_string();
2563
2564 vec![
2565 (
2566 "X-Msh-Platform".to_string(),
2567 sanitize_ascii_header_value("kimi_cli", "unknown"),
2568 ),
2569 (
2570 "X-Msh-Version".to_string(),
2571 sanitize_ascii_header_value(env!("CARGO_PKG_VERSION"), "unknown"),
2572 ),
2573 (
2574 "X-Msh-Device-Name".to_string(),
2575 sanitize_ascii_header_value(&device_name, "unknown"),
2576 ),
2577 (
2578 "X-Msh-Device-Model".to_string(),
2579 sanitize_ascii_header_value(&device_model, "unknown"),
2580 ),
2581 (
2582 "X-Msh-Os-Version".to_string(),
2583 sanitize_ascii_header_value(&os_version, "unknown"),
2584 ),
2585 (
2586 "X-Msh-Device-Id".to_string(),
2587 sanitize_ascii_header_value(&kimi_device_id(), "unknown"),
2588 ),
2589 ]
2590}
2591
2592fn auth_lock_path(path: &Path) -> PathBuf {
2593 let extension = path
2594 .extension()
2595 .and_then(|ext| ext.to_str())
2596 .map_or_else(|| "lock".to_string(), |ext| format!("{ext}.lock"));
2597 path.with_extension(extension)
2598}
2599
2600fn open_auth_lock_file(path: &Path) -> Result<File> {
2601 let lock_path = auth_lock_path(path);
2602 if let Some(parent) = lock_path.parent() {
2603 fs::create_dir_all(parent)?;
2604 }
2605
2606 let mut options = File::options();
2607 options.read(true).write(true).create(true).truncate(false);
2608
2609 #[cfg(unix)]
2610 {
2611 use std::os::unix::fs::OpenOptionsExt;
2612 options.mode(0o600);
2613 }
2614
2615 options
2616 .open(lock_path)
2617 .map_err(|err| Error::auth(format!("auth lock file: {err}")))
2618}
2619
2620#[cfg(unix)]
2621fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
2622 let Some(parent) = path.parent() else {
2623 return Ok(());
2624 };
2625
2626 File::open(parent)?.sync_all()
2627}
2628
2629#[cfg(not(unix))]
2630fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
2631 Ok(())
2632}
2633
2634pub fn start_anthropic_oauth() -> Result<OAuthStartInfo> {
2636 let (verifier, challenge) = generate_pkce();
2637
2638 let client_id = anthropic_oauth_client_id();
2639 let authorize_url = anthropic_oauth_authorize_url();
2640 let redirect_uri = anthropic_oauth_redirect_uri();
2641 let scopes = anthropic_oauth_scopes();
2642 let url = build_url_with_query(
2643 &authorize_url,
2644 &[
2645 ("code", "true"),
2646 ("client_id", &client_id),
2647 ("response_type", "code"),
2648 ("redirect_uri", &redirect_uri),
2649 ("scope", &scopes),
2650 ("code_challenge", &challenge),
2651 ("code_challenge_method", "S256"),
2652 ("state", &verifier),
2653 ],
2654 );
2655
2656 Ok(OAuthStartInfo {
2657 provider: "anthropic".to_string(),
2658 url,
2659 verifier,
2660 instructions: Some(
2661 "Open the URL, complete login, then paste the callback URL or authorization code."
2662 .to_string(),
2663 ),
2664 redirect_uri: Some(redirect_uri),
2665 callback_server: None,
2666 })
2667}
2668
2669pub async fn complete_anthropic_oauth(code_input: &str, verifier: &str) -> Result<AuthCredential> {
2671 let (code, state) = parse_oauth_code_input(code_input);
2672
2673 let Some(code) = code else {
2674 return Err(Error::auth("Missing authorization code".to_string()));
2675 };
2676
2677 let state = state.unwrap_or_else(|| verifier.to_string());
2678 if state != verifier {
2679 return Err(Error::auth("State mismatch".to_string()));
2680 }
2681
2682 let client_id = anthropic_oauth_client_id();
2683 let token_url = anthropic_oauth_token_url();
2684 let redirect_uri = anthropic_oauth_redirect_uri();
2685
2686 let client = crate::http::client::Client::new();
2687 let request = client.post(&token_url).json(&serde_json::json!({
2688 "grant_type": "authorization_code",
2689 "client_id": client_id,
2690 "code": code,
2691 "state": state,
2692 "redirect_uri": redirect_uri,
2693 "code_verifier": verifier,
2694 }))?;
2695
2696 let response = Box::pin(request.send())
2697 .await
2698 .map_err(|e| Error::auth(format!("Token exchange failed: {e}")))?;
2699
2700 let status = response.status();
2701 let text = response
2702 .text()
2703 .await
2704 .unwrap_or_else(|_| "<failed to read body>".to_string());
2705 let redacted_text = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
2706
2707 if !(200..300).contains(&status) {
2708 return Err(Error::auth(format!(
2709 "Token exchange failed: {redacted_text}"
2710 )));
2711 }
2712
2713 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2714 .map_err(|e| Error::auth(format!("Invalid token response: {e}")))?;
2715
2716 Ok(AuthCredential::OAuth {
2717 access_token: oauth_response.access_token,
2718 refresh_token: oauth_response.refresh_token,
2719 expires: oauth_expires_at_ms(oauth_response.expires_in),
2720 token_url: Some(token_url),
2721 client_id: Some(client_id),
2722 })
2723}
2724
2725async fn refresh_anthropic_oauth_token(
2726 client: &crate::http::client::Client,
2727 refresh_token: &str,
2728) -> Result<AuthCredential> {
2729 let client_id = anthropic_oauth_client_id();
2730 let token_url = anthropic_oauth_token_url();
2731
2732 let request = client.post(&token_url).json(&serde_json::json!({
2733 "grant_type": "refresh_token",
2734 "client_id": client_id,
2735 "refresh_token": refresh_token,
2736 }))?;
2737
2738 let response = Box::pin(request.send())
2739 .await
2740 .map_err(|e| Error::auth(format!("Anthropic token refresh failed: {e}")))?;
2741
2742 let status = response.status();
2743 let text = response
2744 .text()
2745 .await
2746 .unwrap_or_else(|_| "<failed to read body>".to_string());
2747 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
2748
2749 if !(200..300).contains(&status) {
2750 return Err(Error::auth(format!(
2751 "Anthropic token refresh failed: {redacted_text}"
2752 )));
2753 }
2754
2755 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2756 .map_err(|e| Error::auth(format!("Invalid refresh response: {e}")))?;
2757
2758 Ok(AuthCredential::OAuth {
2759 access_token: oauth_response.access_token,
2760 refresh_token: oauth_response.refresh_token,
2761 expires: oauth_expires_at_ms(oauth_response.expires_in),
2762 token_url: Some(token_url),
2763 client_id: Some(client_id),
2764 })
2765}
2766
2767pub fn start_openai_codex_oauth() -> Result<OAuthStartInfo> {
2769 let (verifier, challenge) = generate_pkce();
2770 let client_id = openai_codex_oauth_client_id();
2771 let authorize_url = openai_codex_oauth_authorize_url();
2772 let redirect_uri = openai_codex_oauth_redirect_uri();
2773 let scopes = openai_codex_oauth_scopes();
2774 let url = build_url_with_query(
2775 &authorize_url,
2776 &[
2777 ("response_type", "code"),
2778 ("client_id", &client_id),
2779 ("redirect_uri", &redirect_uri),
2780 ("scope", &scopes),
2781 ("code_challenge", &challenge),
2782 ("code_challenge_method", "S256"),
2783 ("state", &verifier),
2784 ("id_token_add_organizations", "true"),
2785 ("codex_cli_simplified_flow", "true"),
2786 ("originator", "pi"),
2787 ],
2788 );
2789
2790 Ok(OAuthStartInfo {
2791 provider: "openai-codex".to_string(),
2792 url,
2793 verifier,
2794 instructions: Some(
2795 "Open the URL, complete login, then paste the callback URL or authorization code."
2796 .to_string(),
2797 ),
2798 redirect_uri: Some(redirect_uri),
2799 callback_server: None,
2800 })
2801}
2802
2803pub async fn complete_openai_codex_oauth(
2805 code_input: &str,
2806 verifier: &str,
2807) -> Result<AuthCredential> {
2808 let (code, state) = parse_oauth_code_input(code_input);
2809 let Some(code) = code else {
2810 return Err(Error::auth("Missing authorization code".to_string()));
2811 };
2812 let state = state.unwrap_or_else(|| verifier.to_string());
2813 if state != verifier {
2814 return Err(Error::auth("State mismatch".to_string()));
2815 }
2816
2817 let client_id = openai_codex_oauth_client_id();
2818 let token_url = openai_codex_oauth_token_url();
2819 let redirect_uri = openai_codex_oauth_redirect_uri();
2820
2821 let form_body = format!(
2822 "grant_type=authorization_code&client_id={}&code={}&code_verifier={}&redirect_uri={}",
2823 percent_encode_component(&client_id),
2824 percent_encode_component(&code),
2825 percent_encode_component(verifier),
2826 percent_encode_component(&redirect_uri),
2827 );
2828
2829 let client = crate::http::client::Client::new();
2830 let request = client
2831 .post(&token_url)
2832 .header("Content-Type", "application/x-www-form-urlencoded")
2833 .header("Accept", "application/json")
2834 .body(form_body.into_bytes());
2835
2836 let response = Box::pin(request.send())
2837 .await
2838 .map_err(|e| Error::auth(format!("OpenAI Codex token exchange failed: {e}")))?;
2839
2840 let status = response.status();
2841 let text = response
2842 .text()
2843 .await
2844 .unwrap_or_else(|_| "<failed to read body>".to_string());
2845 let redacted_text = redact_known_secrets(&text, &[code.as_str(), verifier]);
2846 if !(200..300).contains(&status) {
2847 return Err(Error::auth(format!(
2848 "OpenAI Codex token exchange failed: {redacted_text}"
2849 )));
2850 }
2851
2852 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
2853 .map_err(|e| Error::auth(format!("Invalid OpenAI Codex token response: {e}")))?;
2854
2855 Ok(AuthCredential::OAuth {
2856 access_token: oauth_response.access_token,
2857 refresh_token: oauth_response.refresh_token,
2858 expires: oauth_expires_at_ms(oauth_response.expires_in),
2859 token_url: Some(token_url),
2860 client_id: Some(client_id),
2861 })
2862}
2863
2864pub fn start_google_gemini_cli_oauth() -> Result<OAuthStartInfo> {
2866 let (verifier, challenge) = generate_pkce();
2867 let client_id = google_gemini_cli_oauth_client_id();
2868 let redirect_uri = google_gemini_cli_oauth_redirect_uri();
2869 let url = build_url_with_query(
2870 GOOGLE_GEMINI_CLI_OAUTH_AUTHORIZE_URL,
2871 &[
2872 ("client_id", &client_id),
2873 ("response_type", "code"),
2874 ("redirect_uri", &redirect_uri),
2875 ("scope", GOOGLE_GEMINI_CLI_OAUTH_SCOPES),
2876 ("code_challenge", &challenge),
2877 ("code_challenge_method", "S256"),
2878 ("state", &verifier),
2879 ("access_type", "offline"),
2880 ("prompt", "consent"),
2881 ],
2882 );
2883
2884 Ok(OAuthStartInfo {
2885 provider: "google-gemini-cli".to_string(),
2886 url,
2887 verifier,
2888 instructions: Some(
2889 "Open the URL, complete login, then paste the callback URL or authorization code."
2890 .to_string(),
2891 ),
2892 redirect_uri: Some(redirect_uri),
2893 callback_server: None,
2894 })
2895}
2896
2897pub fn start_google_antigravity_oauth() -> Result<OAuthStartInfo> {
2899 let (verifier, challenge) = generate_pkce();
2900 let client_id = google_antigravity_oauth_client_id();
2901 let redirect_uri = google_antigravity_oauth_redirect_uri();
2902 let url = build_url_with_query(
2903 GOOGLE_ANTIGRAVITY_OAUTH_AUTHORIZE_URL,
2904 &[
2905 ("client_id", &client_id),
2906 ("response_type", "code"),
2907 ("redirect_uri", &redirect_uri),
2908 ("scope", GOOGLE_ANTIGRAVITY_OAUTH_SCOPES),
2909 ("code_challenge", &challenge),
2910 ("code_challenge_method", "S256"),
2911 ("state", &verifier),
2912 ("access_type", "offline"),
2913 ("prompt", "consent"),
2914 ],
2915 );
2916
2917 Ok(OAuthStartInfo {
2918 provider: "google-antigravity".to_string(),
2919 url,
2920 verifier,
2921 instructions: Some(
2922 "Open the URL, complete login, then paste the callback URL or authorization code."
2923 .to_string(),
2924 ),
2925 redirect_uri: Some(redirect_uri),
2926 callback_server: None,
2927 })
2928}
2929
2930async fn discover_google_gemini_cli_project_id(
2931 client: &crate::http::client::Client,
2932 access_token: &str,
2933) -> Result<String> {
2934 let env_project = google_project_id_from_env();
2935 let platform_upper = crate::platform::os_name().to_ascii_uppercase();
2936 let mut payload = serde_json::json!({
2937 "metadata": {
2938 "ideType": "CLI",
2939 "platform": platform_upper,
2940 "pluginType": "GEMINI",
2941 }
2942 });
2943 if let Some(project) = &env_project {
2944 payload["cloudaicompanionProject"] = serde_json::Value::String(project.clone());
2945 payload["metadata"]["duetProject"] = serde_json::Value::String(project.clone());
2946 }
2947
2948 let request = client
2949 .post(&format!(
2950 "{GOOGLE_GEMINI_CLI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist"
2951 ))
2952 .header("Authorization", format!("Bearer {access_token}"))
2953 .header("Content-Type", "application/json")
2954 .json(&payload)?;
2955
2956 let response = Box::pin(request.send())
2957 .await
2958 .map_err(|e| Error::auth(format!("Google Cloud project discovery failed: {e}")))?;
2959 let status = response.status();
2960 let text = response
2961 .text()
2962 .await
2963 .unwrap_or_else(|_| "<failed to read body>".to_string());
2964
2965 if (200..300).contains(&status) {
2966 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2967 if let Some(project_id) = parse_code_assist_project_id(&value) {
2968 return Ok(project_id);
2969 }
2970 }
2971 }
2972
2973 if let Some(project_id) = env_project {
2974 return Ok(project_id);
2975 }
2976
2977 Err(Error::auth(
2978 "Google Cloud project discovery failed. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID and retry /login google-gemini-cli.".to_string(),
2979 ))
2980}
2981
2982async fn discover_google_antigravity_project_id(
2983 client: &crate::http::client::Client,
2984 access_token: &str,
2985) -> Result<String> {
2986 let platform_upper = crate::platform::os_name().to_ascii_uppercase();
2987 let payload = serde_json::json!({
2988 "metadata": {
2989 "ideType": "CLI",
2990 "platform": platform_upper,
2991 "pluginType": "GEMINI",
2992 }
2993 });
2994
2995 for endpoint in GOOGLE_ANTIGRAVITY_PROJECT_DISCOVERY_ENDPOINTS {
2996 let request = client
2997 .post(&format!("{endpoint}/v1internal:loadCodeAssist"))
2998 .header("Authorization", format!("Bearer {access_token}"))
2999 .header("Content-Type", "application/json")
3000 .json(&payload)?;
3001
3002 let Ok(response) = Box::pin(request.send()).await else {
3003 continue;
3004 };
3005 let status = response.status();
3006 if !(200..300).contains(&status) {
3007 continue;
3008 }
3009 let text = response.text().await.unwrap_or_default();
3010 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3011 if let Some(project_id) = parse_code_assist_project_id(&value) {
3012 return Ok(project_id);
3013 }
3014 }
3015 }
3016
3017 Ok(google_antigravity_default_project_id())
3018}
3019
3020fn parse_code_assist_project_id(value: &serde_json::Value) -> Option<String> {
3021 value
3022 .get("cloudaicompanionProject")
3023 .and_then(|project| {
3024 project
3025 .as_str()
3026 .map(std::string::ToString::to_string)
3027 .or_else(|| {
3028 project
3029 .get("id")
3030 .and_then(serde_json::Value::as_str)
3031 .map(std::string::ToString::to_string)
3032 })
3033 })
3034 .map(|project| project.trim().to_string())
3035 .filter(|project| !project.is_empty())
3036}
3037
3038async fn exchange_google_authorization_code(
3039 client: &crate::http::client::Client,
3040 token_url: &str,
3041 client_id: &str,
3042 client_secret: &str,
3043 code: &str,
3044 redirect_uri: &str,
3045 verifier: &str,
3046) -> Result<OAuthTokenResponse> {
3047 let form_body = format!(
3048 "client_id={}&client_secret={}&code={}&grant_type=authorization_code&redirect_uri={}&code_verifier={}",
3049 percent_encode_component(client_id),
3050 percent_encode_component(client_secret),
3051 percent_encode_component(code),
3052 percent_encode_component(redirect_uri),
3053 percent_encode_component(verifier),
3054 );
3055
3056 let request = client
3057 .post(token_url)
3058 .header("Content-Type", "application/x-www-form-urlencoded")
3059 .header("Accept", "application/json")
3060 .body(form_body.into_bytes());
3061
3062 let response = Box::pin(request.send())
3063 .await
3064 .map_err(|e| Error::auth(format!("OAuth token exchange failed: {e}")))?;
3065 let status = response.status();
3066 let text = response
3067 .text()
3068 .await
3069 .unwrap_or_else(|_| "<failed to read body>".to_string());
3070 let redacted_text = redact_known_secrets(&text, &[code, verifier, client_secret]);
3071 if !(200..300).contains(&status) {
3072 return Err(Error::auth(format!(
3073 "OAuth token exchange failed: {redacted_text}"
3074 )));
3075 }
3076
3077 serde_json::from_str::<OAuthTokenResponse>(&text)
3078 .map_err(|e| Error::auth(format!("Invalid OAuth token response: {e}")))
3079}
3080
3081pub async fn complete_google_gemini_cli_oauth(
3083 code_input: &str,
3084 verifier: &str,
3085) -> Result<AuthCredential> {
3086 let (code, state) = parse_oauth_code_input(code_input);
3087 let Some(code) = code else {
3088 return Err(Error::auth("Missing authorization code".to_string()));
3089 };
3090 let state = state.unwrap_or_else(|| verifier.to_string());
3091 if state != verifier {
3092 return Err(Error::auth("State mismatch".to_string()));
3093 }
3094
3095 let client = crate::http::client::Client::new();
3096 let gc_client_id = google_gemini_cli_oauth_client_id();
3097 let gc_client_secret = google_gemini_cli_oauth_client_secret();
3098 let gc_redirect_uri = google_gemini_cli_oauth_redirect_uri();
3099 let oauth_response = exchange_google_authorization_code(
3100 &client,
3101 GOOGLE_GEMINI_CLI_OAUTH_TOKEN_URL,
3102 &gc_client_id,
3103 &gc_client_secret,
3104 &code,
3105 &gc_redirect_uri,
3106 verifier,
3107 )
3108 .await?;
3109
3110 let project_id =
3111 discover_google_gemini_cli_project_id(&client, &oauth_response.access_token).await?;
3112
3113 Ok(AuthCredential::OAuth {
3114 access_token: encode_project_scoped_access_token(&oauth_response.access_token, &project_id),
3115 refresh_token: oauth_response.refresh_token,
3116 expires: oauth_expires_at_ms(oauth_response.expires_in),
3117 token_url: None,
3118 client_id: None,
3119 })
3120}
3121
3122pub async fn complete_google_antigravity_oauth(
3124 code_input: &str,
3125 verifier: &str,
3126) -> Result<AuthCredential> {
3127 let (code, state) = parse_oauth_code_input(code_input);
3128 let Some(code) = code else {
3129 return Err(Error::auth("Missing authorization code".to_string()));
3130 };
3131 let state = state.unwrap_or_else(|| verifier.to_string());
3132 if state != verifier {
3133 return Err(Error::auth("State mismatch".to_string()));
3134 }
3135
3136 let client = crate::http::client::Client::new();
3137 let ga_client_id = google_antigravity_oauth_client_id();
3138 let ga_client_secret = google_antigravity_oauth_client_secret();
3139 let ga_redirect_uri = google_antigravity_oauth_redirect_uri();
3140 let oauth_response = exchange_google_authorization_code(
3141 &client,
3142 GOOGLE_ANTIGRAVITY_OAUTH_TOKEN_URL,
3143 &ga_client_id,
3144 &ga_client_secret,
3145 &code,
3146 &ga_redirect_uri,
3147 verifier,
3148 )
3149 .await?;
3150
3151 let project_id =
3152 discover_google_antigravity_project_id(&client, &oauth_response.access_token).await?;
3153
3154 Ok(AuthCredential::OAuth {
3155 access_token: encode_project_scoped_access_token(&oauth_response.access_token, &project_id),
3156 refresh_token: oauth_response.refresh_token,
3157 expires: oauth_expires_at_ms(oauth_response.expires_in),
3158 token_url: None,
3159 client_id: None,
3160 })
3161}
3162
3163#[derive(Debug, Deserialize)]
3164struct OAuthRefreshTokenResponse {
3165 access_token: String,
3166 #[serde(default)]
3167 refresh_token: Option<String>,
3168 expires_in: i64,
3169}
3170
3171async fn refresh_google_oauth_token_with_project(
3172 client: &crate::http::client::Client,
3173 token_url: &str,
3174 client_id: &str,
3175 client_secret: &str,
3176 refresh_token: &str,
3177 project_id: &str,
3178 provider_name: &str,
3179) -> Result<AuthCredential> {
3180 let form_body = format!(
3181 "client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token",
3182 percent_encode_component(client_id),
3183 percent_encode_component(client_secret),
3184 percent_encode_component(refresh_token),
3185 );
3186
3187 let request = client
3188 .post(token_url)
3189 .header("Content-Type", "application/x-www-form-urlencoded")
3190 .header("Accept", "application/json")
3191 .body(form_body.into_bytes());
3192
3193 let response = Box::pin(request.send())
3194 .await
3195 .map_err(|e| Error::auth(format!("{provider_name} token refresh failed: {e}")))?;
3196 let status = response.status();
3197 let text = response
3198 .text()
3199 .await
3200 .unwrap_or_else(|_| "<failed to read body>".to_string());
3201 let redacted_text = redact_known_secrets(&text, &[client_secret, refresh_token]);
3202 if !(200..300).contains(&status) {
3203 return Err(Error::auth(format!(
3204 "{provider_name} token refresh failed: {redacted_text}"
3205 )));
3206 }
3207
3208 let oauth_response: OAuthRefreshTokenResponse = serde_json::from_str(&text)
3209 .map_err(|e| Error::auth(format!("Invalid {provider_name} refresh response: {e}")))?;
3210
3211 Ok(AuthCredential::OAuth {
3212 access_token: encode_project_scoped_access_token(&oauth_response.access_token, project_id),
3213 refresh_token: oauth_response
3214 .refresh_token
3215 .unwrap_or_else(|| refresh_token.to_string()),
3216 expires: oauth_expires_at_ms(oauth_response.expires_in),
3217 token_url: None,
3218 client_id: None,
3219 })
3220}
3221
3222async fn refresh_google_gemini_cli_oauth_token(
3223 client: &crate::http::client::Client,
3224 refresh_token: &str,
3225 project_id: &str,
3226) -> Result<AuthCredential> {
3227 let gc_client_id = google_gemini_cli_oauth_client_id();
3228 let gc_client_secret = google_gemini_cli_oauth_client_secret();
3229 refresh_google_oauth_token_with_project(
3230 client,
3231 GOOGLE_GEMINI_CLI_OAUTH_TOKEN_URL,
3232 &gc_client_id,
3233 &gc_client_secret,
3234 refresh_token,
3235 project_id,
3236 "google-gemini-cli",
3237 )
3238 .await
3239}
3240
3241async fn refresh_google_antigravity_oauth_token(
3242 client: &crate::http::client::Client,
3243 refresh_token: &str,
3244 project_id: &str,
3245) -> Result<AuthCredential> {
3246 let ga_client_id = google_antigravity_oauth_client_id();
3247 let ga_client_secret = google_antigravity_oauth_client_secret();
3248 refresh_google_oauth_token_with_project(
3249 client,
3250 GOOGLE_ANTIGRAVITY_OAUTH_TOKEN_URL,
3251 &ga_client_id,
3252 &ga_client_secret,
3253 refresh_token,
3254 project_id,
3255 "google-antigravity",
3256 )
3257 .await
3258}
3259
3260pub async fn start_kimi_code_device_flow() -> Result<DeviceCodeResponse> {
3262 let client = crate::http::client::Client::new();
3263 start_kimi_code_device_flow_with_client(&client, &kimi_code_oauth_host()).await
3264}
3265
3266async fn start_kimi_code_device_flow_with_client(
3267 client: &crate::http::client::Client,
3268 oauth_host: &str,
3269) -> Result<DeviceCodeResponse> {
3270 let kimi_client_id = kimi_code_oauth_client_id();
3271 let url = kimi_code_endpoint_for_host(oauth_host, KIMI_CODE_DEVICE_AUTHORIZATION_PATH);
3272 let form_body = format!("client_id={}", percent_encode_component(&kimi_client_id));
3273 let mut request = client
3274 .post(&url)
3275 .header("Content-Type", "application/x-www-form-urlencoded")
3276 .header("Accept", "application/json")
3277 .body(form_body.into_bytes());
3278 for (name, value) in kimi_common_headers() {
3279 request = request.header(name, value);
3280 }
3281
3282 let response = Box::pin(request.send())
3283 .await
3284 .map_err(|e| Error::auth(format!("Kimi device authorization request failed: {e}")))?;
3285 let status = response.status();
3286 let text = response
3287 .text()
3288 .await
3289 .unwrap_or_else(|_| "<failed to read body>".to_string());
3290 let redacted_text = redact_known_secrets(&text, &[&kimi_client_id]);
3291 if !(200..300).contains(&status) {
3292 return Err(Error::auth(format!(
3293 "Kimi device authorization failed (HTTP {status}): {redacted_text}"
3294 )));
3295 }
3296
3297 serde_json::from_str(&text)
3298 .map_err(|e| Error::auth(format!("Invalid Kimi device authorization response: {e}")))
3299}
3300
3301pub async fn poll_kimi_code_device_flow(device_code: &str) -> DeviceFlowPollResult {
3303 let client = crate::http::client::Client::new();
3304 poll_kimi_code_device_flow_with_client(&client, &kimi_code_oauth_host(), device_code).await
3305}
3306
3307async fn poll_kimi_code_device_flow_with_client(
3308 client: &crate::http::client::Client,
3309 oauth_host: &str,
3310 device_code: &str,
3311) -> DeviceFlowPollResult {
3312 let kimi_client_id = kimi_code_oauth_client_id();
3313 let token_url = kimi_code_endpoint_for_host(oauth_host, KIMI_CODE_TOKEN_PATH);
3314 let form_body = format!(
3315 "client_id={}&device_code={}&grant_type={}",
3316 percent_encode_component(&kimi_client_id),
3317 percent_encode_component(device_code),
3318 percent_encode_component("urn:ietf:params:oauth:grant-type:device_code"),
3319 );
3320 let mut request = client
3321 .post(&token_url)
3322 .header("Content-Type", "application/x-www-form-urlencoded")
3323 .header("Accept", "application/json")
3324 .body(form_body.into_bytes());
3325 for (name, value) in kimi_common_headers() {
3326 request = request.header(name, value);
3327 }
3328
3329 let response = match Box::pin(request.send()).await {
3330 Ok(response) => response,
3331 Err(err) => return DeviceFlowPollResult::Error(format!("Poll request failed: {err}")),
3332 };
3333 let status = response.status();
3334 let text = response
3335 .text()
3336 .await
3337 .unwrap_or_else(|_| "<failed to read body>".to_string());
3338 let json: serde_json::Value = match serde_json::from_str(&text) {
3339 Ok(value) => value,
3340 Err(err) => {
3341 return DeviceFlowPollResult::Error(format!("Invalid poll response JSON: {err}"));
3342 }
3343 };
3344
3345 if let Some(error) = json.get("error").and_then(serde_json::Value::as_str) {
3346 return match error {
3347 "authorization_pending" => DeviceFlowPollResult::Pending,
3348 "slow_down" => DeviceFlowPollResult::SlowDown,
3349 "expired_token" => DeviceFlowPollResult::Expired,
3350 "access_denied" => DeviceFlowPollResult::AccessDenied,
3351 other => {
3352 let detail = json
3353 .get("error_description")
3354 .and_then(serde_json::Value::as_str)
3355 .unwrap_or("unknown error");
3356 DeviceFlowPollResult::Error(format!("Kimi device flow error: {other}: {detail}"))
3357 }
3358 };
3359 }
3360
3361 if !(200..300).contains(&status) {
3362 return DeviceFlowPollResult::Error(format!(
3363 "Kimi device flow polling failed (HTTP {status}): {}",
3364 redact_known_secrets(&text, &[device_code]),
3365 ));
3366 }
3367
3368 let oauth_response: OAuthTokenResponse = match serde_json::from_value(json) {
3369 Ok(response) => response,
3370 Err(err) => {
3371 return DeviceFlowPollResult::Error(format!(
3372 "Invalid Kimi token response payload: {err}"
3373 ));
3374 }
3375 };
3376
3377 DeviceFlowPollResult::Success(AuthCredential::OAuth {
3378 access_token: oauth_response.access_token,
3379 refresh_token: oauth_response.refresh_token,
3380 expires: oauth_expires_at_ms(oauth_response.expires_in),
3381 token_url: Some(token_url),
3382 client_id: Some(kimi_client_id),
3383 })
3384}
3385
3386async fn refresh_kimi_code_oauth_token(
3387 client: &crate::http::client::Client,
3388 token_url: &str,
3389 refresh_token: &str,
3390) -> Result<AuthCredential> {
3391 let kimi_client_id = kimi_code_oauth_client_id();
3392 let form_body = format!(
3393 "client_id={}&grant_type=refresh_token&refresh_token={}",
3394 percent_encode_component(&kimi_client_id),
3395 percent_encode_component(refresh_token),
3396 );
3397 let mut request = client
3398 .post(token_url)
3399 .header("Content-Type", "application/x-www-form-urlencoded")
3400 .header("Accept", "application/json")
3401 .body(form_body.into_bytes());
3402 for (name, value) in kimi_common_headers() {
3403 request = request.header(name, value);
3404 }
3405
3406 let response = Box::pin(request.send())
3407 .await
3408 .map_err(|e| Error::auth(format!("Kimi token refresh failed: {e}")))?;
3409 let status = response.status();
3410 let text = response
3411 .text()
3412 .await
3413 .unwrap_or_else(|_| "<failed to read body>".to_string());
3414 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
3415 if !(200..300).contains(&status) {
3416 return Err(Error::auth(format!(
3417 "Kimi token refresh failed (HTTP {status}): {redacted_text}"
3418 )));
3419 }
3420
3421 let oauth_response: OAuthRefreshTokenResponse = serde_json::from_str(&text)
3422 .map_err(|e| Error::auth(format!("Invalid Kimi refresh response: {e}")))?;
3423
3424 Ok(AuthCredential::OAuth {
3425 access_token: oauth_response.access_token,
3426 refresh_token: oauth_response
3427 .refresh_token
3428 .unwrap_or_else(|| refresh_token.to_string()),
3429 expires: oauth_expires_at_ms(oauth_response.expires_in),
3430 token_url: Some(token_url.to_string()),
3431 client_id: Some(kimi_client_id),
3432 })
3433}
3434
3435pub fn start_extension_oauth(
3437 provider_name: &str,
3438 config: &crate::models::OAuthConfig,
3439) -> Result<OAuthStartInfo> {
3440 let (verifier, challenge) = generate_pkce();
3441 let scopes = config.scopes.join(" ");
3442
3443 let mut params: Vec<(&str, &str)> = vec![
3444 ("client_id", &config.client_id),
3445 ("response_type", "code"),
3446 ("scope", &scopes),
3447 ("code_challenge", &challenge),
3448 ("code_challenge_method", "S256"),
3449 ("state", &verifier),
3450 ];
3451
3452 let redirect_uri_ref = config.redirect_uri.as_deref();
3453 if let Some(uri) = redirect_uri_ref {
3454 params.push(("redirect_uri", uri));
3455 }
3456
3457 let url = build_url_with_query(&config.auth_url, ¶ms);
3458
3459 Ok(OAuthStartInfo {
3460 provider: provider_name.to_string(),
3461 url,
3462 verifier,
3463 instructions: Some(
3464 "Open the URL, complete login, then paste the callback URL or authorization code."
3465 .to_string(),
3466 ),
3467 redirect_uri: config.redirect_uri.clone(),
3468 callback_server: None,
3469 })
3470}
3471
3472pub async fn complete_extension_oauth(
3474 config: &crate::models::OAuthConfig,
3475 code_input: &str,
3476 verifier: &str,
3477) -> Result<AuthCredential> {
3478 let (code, state) = parse_oauth_code_input(code_input);
3479
3480 let Some(code) = code else {
3481 return Err(Error::auth("Missing authorization code".to_string()));
3482 };
3483
3484 let state = state.unwrap_or_else(|| verifier.to_string());
3485 if state != verifier {
3486 return Err(Error::auth("State mismatch".to_string()));
3487 }
3488
3489 let client = crate::http::client::Client::new();
3490
3491 let mut body = serde_json::json!({
3492 "grant_type": "authorization_code",
3493 "client_id": config.client_id,
3494 "code": code,
3495 "state": state,
3496 "code_verifier": verifier,
3497 });
3498
3499 if let Some(ref redirect_uri) = config.redirect_uri {
3500 body["redirect_uri"] = serde_json::Value::String(redirect_uri.clone());
3501 }
3502
3503 let request = client.post(&config.token_url).json(&body)?;
3504
3505 let response = Box::pin(request.send())
3506 .await
3507 .map_err(|e| Error::auth(format!("Token exchange failed: {e}")))?;
3508
3509 let status = response.status();
3510 let text = response
3511 .text()
3512 .await
3513 .unwrap_or_else(|_| "<failed to read body>".to_string());
3514 let redacted_text = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
3515
3516 if !(200..300).contains(&status) {
3517 return Err(Error::auth(format!(
3518 "Token exchange failed: {redacted_text}"
3519 )));
3520 }
3521
3522 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
3523 .map_err(|e| Error::auth(format!("Invalid token response: {e}")))?;
3524
3525 Ok(AuthCredential::OAuth {
3526 access_token: oauth_response.access_token,
3527 refresh_token: oauth_response.refresh_token,
3528 expires: oauth_expires_at_ms(oauth_response.expires_in),
3529 token_url: Some(config.token_url.clone()),
3530 client_id: Some(config.client_id.clone()),
3531 })
3532}
3533
3534async fn refresh_extension_oauth_token(
3536 client: &crate::http::client::Client,
3537 config: &crate::models::OAuthConfig,
3538 refresh_token: &str,
3539) -> Result<AuthCredential> {
3540 let request = client.post(&config.token_url).json(&serde_json::json!({
3541 "grant_type": "refresh_token",
3542 "client_id": config.client_id,
3543 "refresh_token": refresh_token,
3544 }))?;
3545
3546 let response = Box::pin(request.send())
3547 .await
3548 .map_err(|e| Error::auth(format!("Extension OAuth token refresh failed: {e}")))?;
3549
3550 let status = response.status();
3551 let text = response
3552 .text()
3553 .await
3554 .unwrap_or_else(|_| "<failed to read body>".to_string());
3555 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
3556
3557 if !(200..300).contains(&status) {
3558 return Err(Error::auth(format!(
3559 "Extension OAuth token refresh failed: {redacted_text}"
3560 )));
3561 }
3562
3563 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
3564 .map_err(|e| Error::auth(format!("Invalid refresh response: {e}")))?;
3565
3566 Ok(AuthCredential::OAuth {
3567 access_token: oauth_response.access_token,
3568 refresh_token: oauth_response.refresh_token,
3569 expires: oauth_expires_at_ms(oauth_response.expires_in),
3570 token_url: Some(config.token_url.clone()),
3571 client_id: Some(config.client_id.clone()),
3572 })
3573}
3574
3575async fn refresh_self_contained_oauth_token(
3581 client: &crate::http::client::Client,
3582 token_url: &str,
3583 oauth_client_id: &str,
3584 refresh_token: &str,
3585 provider: &str,
3586) -> Result<AuthCredential> {
3587 let request = client.post(token_url).json(&serde_json::json!({
3588 "grant_type": "refresh_token",
3589 "client_id": oauth_client_id,
3590 "refresh_token": refresh_token,
3591 }))?;
3592
3593 let response = Box::pin(request.send())
3594 .await
3595 .map_err(|e| Error::auth(format!("{provider} token refresh failed: {e}")))?;
3596
3597 let status = response.status();
3598 let text = response
3599 .text()
3600 .await
3601 .unwrap_or_else(|_| "<failed to read body>".to_string());
3602 let redacted_text = redact_known_secrets(&text, &[refresh_token]);
3603
3604 if !(200..300).contains(&status) {
3605 return Err(Error::auth(format!(
3606 "{provider} token refresh failed (HTTP {status}): {redacted_text}"
3607 )));
3608 }
3609
3610 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text)
3611 .map_err(|e| Error::auth(format!("Invalid refresh response from {provider}: {e}")))?;
3612
3613 Ok(AuthCredential::OAuth {
3614 access_token: oauth_response.access_token,
3615 refresh_token: oauth_response.refresh_token,
3616 expires: oauth_expires_at_ms(oauth_response.expires_in),
3617 token_url: Some(token_url.to_string()),
3618 client_id: Some(oauth_client_id.to_string()),
3619 })
3620}
3621
3622pub fn start_copilot_browser_oauth(config: &CopilotOAuthConfig) -> Result<OAuthStartInfo> {
3630 if config.client_id.is_empty() {
3631 return Err(Error::auth(
3632 "GitHub Copilot OAuth requires a client_id. Set GITHUB_COPILOT_CLIENT_ID or \
3633 configure the GitHub App in your settings."
3634 .to_string(),
3635 ));
3636 }
3637
3638 let (verifier, challenge) = generate_pkce();
3639
3640 let auth_url = if config.github_base_url == "https://github.com" {
3641 GITHUB_OAUTH_AUTHORIZE_URL.to_string()
3642 } else {
3643 format!(
3644 "{}/login/oauth/authorize",
3645 trim_trailing_slash(&config.github_base_url)
3646 )
3647 };
3648
3649 let callback = start_oauth_callback_server_random_port().ok();
3653 let redirect_uri = callback.as_ref().map(|(_, uri)| uri.clone());
3654
3655 let mut params: Vec<(&str, &str)> = vec![
3656 ("client_id", &config.client_id),
3657 ("response_type", "code"),
3658 ("scope", &config.scopes),
3659 ("code_challenge", &challenge),
3660 ("code_challenge_method", "S256"),
3661 ("state", &verifier),
3662 ];
3663 let redirect_ref = redirect_uri.as_deref();
3664 if let Some(uri) = redirect_ref {
3665 params.push(("redirect_uri", uri));
3666 }
3667
3668 let url = build_url_with_query(&auth_url, ¶ms);
3669
3670 Ok(OAuthStartInfo {
3671 provider: "github-copilot".to_string(),
3672 url,
3673 verifier,
3674 instructions: Some(
3675 "Open the URL in your browser to authorize GitHub Copilot access, \
3676 then paste the callback URL or authorization code."
3677 .to_string(),
3678 ),
3679 redirect_uri,
3680 callback_server: callback.map(|(server, _)| server),
3683 })
3684}
3685
3686pub async fn complete_copilot_browser_oauth(
3688 config: &CopilotOAuthConfig,
3689 code_input: &str,
3690 verifier: &str,
3691 redirect_uri: Option<&str>,
3692) -> Result<AuthCredential> {
3693 let (code, state) = parse_oauth_code_input(code_input);
3694
3695 let Some(code) = code else {
3696 return Err(Error::auth(
3697 "Missing authorization code. Paste the full callback URL or just the code parameter."
3698 .to_string(),
3699 ));
3700 };
3701
3702 let state = state.unwrap_or_else(|| verifier.to_string());
3703 if state != verifier {
3704 return Err(Error::auth("State mismatch".to_string()));
3705 }
3706
3707 let token_url_str = if config.github_base_url == "https://github.com" {
3708 GITHUB_OAUTH_TOKEN_URL.to_string()
3709 } else {
3710 format!(
3711 "{}/login/oauth/access_token",
3712 trim_trailing_slash(&config.github_base_url)
3713 )
3714 };
3715
3716 let client = crate::http::client::Client::new();
3717 let mut body = serde_json::json!({
3718 "grant_type": "authorization_code",
3719 "client_id": config.client_id,
3720 "code": code,
3721 "state": state,
3722 "code_verifier": verifier,
3723 });
3724 if let Some(uri) = redirect_uri {
3727 body["redirect_uri"] = serde_json::Value::String(uri.to_string());
3728 }
3729 let request = client
3730 .post(&token_url_str)
3731 .header("Accept", "application/json")
3732 .json(&body)?;
3733
3734 let response = Box::pin(request.send())
3735 .await
3736 .map_err(|e| Error::auth(format!("GitHub token exchange failed: {e}")))?;
3737
3738 let status = response.status();
3739 let text = response
3740 .text()
3741 .await
3742 .unwrap_or_else(|_| "<failed to read body>".to_string());
3743 let redacted = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
3744
3745 if !(200..300).contains(&status) {
3746 return Err(Error::auth(copilot_diagnostic(
3747 &format!("Token exchange failed (HTTP {status})"),
3748 &redacted,
3749 )));
3750 }
3751
3752 let mut cred = parse_github_token_response(&text)?;
3753 if let AuthCredential::OAuth {
3755 ref mut token_url,
3756 ref mut client_id,
3757 ..
3758 } = cred
3759 {
3760 *token_url = Some(token_url_str.clone());
3761 *client_id = Some(config.client_id.clone());
3762 }
3763 Ok(cred)
3764}
3765
3766pub async fn start_copilot_device_flow(config: &CopilotOAuthConfig) -> Result<DeviceCodeResponse> {
3771 if config.client_id.is_empty() {
3772 return Err(Error::auth(
3773 "GitHub Copilot device flow requires a client_id. Set GITHUB_COPILOT_CLIENT_ID or \
3774 configure the GitHub App in your settings."
3775 .to_string(),
3776 ));
3777 }
3778
3779 let device_url = if config.github_base_url == "https://github.com" {
3780 GITHUB_DEVICE_CODE_URL.to_string()
3781 } else {
3782 format!(
3783 "{}/login/device/code",
3784 trim_trailing_slash(&config.github_base_url)
3785 )
3786 };
3787
3788 let client = crate::http::client::Client::new();
3789 let request = client
3790 .post(&device_url)
3791 .header("Accept", "application/json")
3792 .json(&serde_json::json!({
3793 "client_id": config.client_id,
3794 "scope": config.scopes,
3795 }))?;
3796
3797 let response = Box::pin(request.send())
3798 .await
3799 .map_err(|e| Error::auth(format!("GitHub device code request failed: {e}")))?;
3800
3801 let status = response.status();
3802 let text = response
3803 .text()
3804 .await
3805 .unwrap_or_else(|_| "<failed to read body>".to_string());
3806
3807 if !(200..300).contains(&status) {
3808 return Err(Error::auth(copilot_diagnostic(
3809 &format!("Device code request failed (HTTP {status})"),
3810 &redact_known_secrets(&text, &[]),
3811 )));
3812 }
3813
3814 serde_json::from_str(&text).map_err(|e| {
3815 Error::auth(format!(
3816 "Invalid device code response: {e}. \
3817 Ensure the GitHub App has the Device Flow enabled."
3818 ))
3819 })
3820}
3821
3822pub async fn poll_copilot_device_flow(
3827 config: &CopilotOAuthConfig,
3828 device_code: &str,
3829) -> DeviceFlowPollResult {
3830 let token_url = if config.github_base_url == "https://github.com" {
3831 GITHUB_OAUTH_TOKEN_URL.to_string()
3832 } else {
3833 format!(
3834 "{}/login/oauth/access_token",
3835 trim_trailing_slash(&config.github_base_url)
3836 )
3837 };
3838
3839 let client = crate::http::client::Client::new();
3840 let request = match client
3841 .post(&token_url)
3842 .header("Accept", "application/json")
3843 .json(&serde_json::json!({
3844 "client_id": config.client_id,
3845 "device_code": device_code,
3846 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
3847 })) {
3848 Ok(r) => r,
3849 Err(e) => return DeviceFlowPollResult::Error(format!("Request build failed: {e}")),
3850 };
3851
3852 let response = match Box::pin(request.send()).await {
3853 Ok(r) => r,
3854 Err(e) => return DeviceFlowPollResult::Error(format!("Poll request failed: {e}")),
3855 };
3856
3857 let text = response
3858 .text()
3859 .await
3860 .unwrap_or_else(|_| "<failed to read body>".to_string());
3861
3862 let json: serde_json::Value = match serde_json::from_str(&text) {
3864 Ok(v) => v,
3865 Err(e) => {
3866 return DeviceFlowPollResult::Error(format!("Invalid poll response: {e}"));
3867 }
3868 };
3869
3870 if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
3871 return match error {
3872 "authorization_pending" => DeviceFlowPollResult::Pending,
3873 "slow_down" => DeviceFlowPollResult::SlowDown,
3874 "expired_token" => DeviceFlowPollResult::Expired,
3875 "access_denied" => DeviceFlowPollResult::AccessDenied,
3876 other => DeviceFlowPollResult::Error(format!(
3877 "GitHub device flow error: {other}. {}",
3878 json.get("error_description")
3879 .and_then(|v| v.as_str())
3880 .unwrap_or("Check your GitHub App configuration.")
3881 )),
3882 };
3883 }
3884
3885 match parse_github_token_response(&text) {
3886 Ok(cred) => DeviceFlowPollResult::Success(cred),
3887 Err(e) => DeviceFlowPollResult::Error(e.to_string()),
3888 }
3889}
3890
3891fn parse_github_token_response(text: &str) -> Result<AuthCredential> {
3896 let json: serde_json::Value =
3897 serde_json::from_str(text).map_err(|e| Error::auth(format!("Invalid token JSON: {e}")))?;
3898
3899 let access_token = json
3900 .get("access_token")
3901 .and_then(|v| v.as_str())
3902 .ok_or_else(|| Error::auth("Missing access_token in GitHub response".to_string()))?
3903 .to_string();
3904
3905 let refresh_token = json
3907 .get("refresh_token")
3908 .and_then(|v| v.as_str())
3909 .unwrap_or("")
3910 .to_string();
3911
3912 let expires = json
3913 .get("expires_in")
3914 .and_then(serde_json::Value::as_i64)
3915 .map_or_else(
3916 || {
3917 oauth_expires_at_ms(365 * 24 * 3600)
3919 },
3920 oauth_expires_at_ms,
3921 );
3922
3923 Ok(AuthCredential::OAuth {
3924 access_token,
3925 refresh_token,
3926 expires,
3927 token_url: None,
3930 client_id: None,
3931 })
3932}
3933
3934fn copilot_diagnostic(summary: &str, detail: &str) -> String {
3936 format!(
3937 "{summary}: {detail}\n\
3938 Troubleshooting:\n\
3939 - Verify the GitHub App client_id is correct\n\
3940 - Ensure your GitHub account has an active Copilot subscription\n\
3941 - For GitHub Enterprise, set the correct base URL\n\
3942 - Check https://github.com/settings/applications for app authorization status"
3943 )
3944}
3945
3946pub fn start_gitlab_oauth(config: &GitLabOAuthConfig) -> Result<OAuthStartInfo> {
3953 if config.client_id.is_empty() {
3954 return Err(Error::auth(
3955 "GitLab OAuth requires a client_id. Create an application at \
3956 Settings > Applications in your GitLab instance."
3957 .to_string(),
3958 ));
3959 }
3960
3961 let (verifier, challenge) = generate_pkce();
3962 let base = trim_trailing_slash(&config.base_url);
3963 let auth_url = format!("{base}{GITLAB_OAUTH_AUTHORIZE_PATH}");
3964
3965 let (redirect_uri, callback_server) = if config.redirect_uri.is_some() {
3968 (config.redirect_uri.clone(), None)
3969 } else {
3970 match start_oauth_callback_server_random_port() {
3971 Ok((server, uri)) => (Some(uri), Some(server)),
3972 Err(_) => (None, None),
3973 }
3974 };
3975
3976 let mut params: Vec<(&str, &str)> = vec![
3977 ("client_id", &config.client_id),
3978 ("response_type", "code"),
3979 ("scope", &config.scopes),
3980 ("code_challenge", &challenge),
3981 ("code_challenge_method", "S256"),
3982 ("state", &verifier),
3983 ];
3984
3985 let redirect_ref = redirect_uri.as_deref();
3986 if let Some(uri) = redirect_ref {
3987 params.push(("redirect_uri", uri));
3988 }
3989
3990 let url = build_url_with_query(&auth_url, ¶ms);
3991
3992 Ok(OAuthStartInfo {
3993 provider: "gitlab".to_string(),
3994 url,
3995 verifier,
3996 instructions: Some(format!(
3997 "Open the URL to authorize GitLab access on {base}, \
3998 then paste the callback URL or authorization code."
3999 )),
4000 redirect_uri,
4001 callback_server,
4002 })
4003}
4004
4005pub async fn complete_gitlab_oauth(
4007 config: &GitLabOAuthConfig,
4008 code_input: &str,
4009 verifier: &str,
4010 redirect_uri: Option<&str>,
4011) -> Result<AuthCredential> {
4012 let (code, state) = parse_oauth_code_input(code_input);
4013
4014 let Some(code) = code else {
4015 return Err(Error::auth(
4016 "Missing authorization code. Paste the full callback URL or just the code parameter."
4017 .to_string(),
4018 ));
4019 };
4020
4021 let state = state.unwrap_or_else(|| verifier.to_string());
4022 if state != verifier {
4023 return Err(Error::auth("State mismatch".to_string()));
4024 }
4025 let base = trim_trailing_slash(&config.base_url);
4026 let token_url = format!("{base}{GITLAB_OAUTH_TOKEN_PATH}");
4027
4028 let client = crate::http::client::Client::new();
4029
4030 let mut body = serde_json::json!({
4031 "grant_type": "authorization_code",
4032 "client_id": config.client_id,
4033 "code": code,
4034 "state": state,
4035 "code_verifier": verifier,
4036 });
4037
4038 if let Some(uri) = redirect_uri {
4039 body["redirect_uri"] = serde_json::Value::String(uri.to_string());
4040 }
4041
4042 let request = client
4043 .post(&token_url)
4044 .header("Accept", "application/json")
4045 .json(&body)?;
4046
4047 let response = Box::pin(request.send())
4048 .await
4049 .map_err(|e| Error::auth(format!("GitLab token exchange failed: {e}")))?;
4050
4051 let status = response.status();
4052 let text = response
4053 .text()
4054 .await
4055 .unwrap_or_else(|_| "<failed to read body>".to_string());
4056 let redacted = redact_known_secrets(&text, &[code.as_str(), verifier, state.as_str()]);
4057
4058 if !(200..300).contains(&status) {
4059 return Err(Error::auth(gitlab_diagnostic(
4060 &config.base_url,
4061 &format!("Token exchange failed (HTTP {status})"),
4062 &redacted,
4063 )));
4064 }
4065
4066 let oauth_response: OAuthTokenResponse = serde_json::from_str(&text).map_err(|e| {
4067 Error::auth(gitlab_diagnostic(
4068 &config.base_url,
4069 &format!("Invalid token response: {e}"),
4070 &redacted,
4071 ))
4072 })?;
4073
4074 let base = trim_trailing_slash(&config.base_url);
4075 Ok(AuthCredential::OAuth {
4076 access_token: oauth_response.access_token,
4077 refresh_token: oauth_response.refresh_token,
4078 expires: oauth_expires_at_ms(oauth_response.expires_in),
4079 token_url: Some(format!("{base}{GITLAB_OAUTH_TOKEN_PATH}")),
4080 client_id: Some(config.client_id.clone()),
4081 })
4082}
4083
4084fn gitlab_diagnostic(base_url: &str, summary: &str, detail: &str) -> String {
4086 format!(
4087 "{summary}: {detail}\n\
4088 Troubleshooting:\n\
4089 - Verify the application client_id matches your GitLab application\n\
4090 - Check Settings > Applications on {base_url}\n\
4091 - Ensure the redirect URI matches your application configuration\n\
4092 - For self-hosted GitLab, verify the base URL is correct ({base_url})"
4093 )
4094}
4095
4096fn trim_trailing_slash(url: &str) -> &str {
4114 url.trim_end_matches('/')
4115}
4116
4117#[derive(Debug, Deserialize)]
4118struct OAuthTokenResponse {
4119 access_token: String,
4120 refresh_token: String,
4121 expires_in: i64,
4122}
4123
4124fn oauth_expires_at_ms(expires_in_seconds: i64) -> i64 {
4125 const SAFETY_MARGIN_MS: i64 = 5 * 60 * 1000;
4126 let now_ms = chrono::Utc::now().timestamp_millis();
4127 let expires_ms = expires_in_seconds.saturating_mul(1000);
4128 now_ms
4129 .saturating_add(expires_ms)
4130 .saturating_sub(SAFETY_MARGIN_MS)
4131}
4132
4133fn generate_pkce() -> (String, String) {
4134 let uuid1 = uuid::Uuid::new_v4();
4135 let uuid2 = uuid::Uuid::new_v4();
4136 let mut random = [0u8; 32];
4137 random[..16].copy_from_slice(uuid1.as_bytes());
4138 random[16..].copy_from_slice(uuid2.as_bytes());
4139
4140 let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random);
4141 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
4142 .encode(sha2::Sha256::digest(verifier.as_bytes()));
4143 (verifier, challenge)
4144}
4145
4146fn parse_oauth_code_input(input: &str) -> (Option<String>, Option<String>) {
4147 let value = input.trim();
4148 if value.is_empty() {
4149 return (None, None);
4150 }
4151
4152 if let Some((_, query)) = value.split_once('?') {
4153 let query = query.split('#').next().unwrap_or(query);
4154 let pairs = parse_query_pairs(query);
4155 let code = pairs
4156 .iter()
4157 .find_map(|(k, v)| (k == "code").then(|| v.clone()));
4158 let state = pairs
4159 .iter()
4160 .find_map(|(k, v)| (k == "state").then(|| v.clone()));
4161 return (code, state);
4162 }
4163
4164 if let Some((code, state)) = value.split_once('#') {
4165 let code = code.trim();
4166 let state = state.trim();
4167 return (
4168 (!code.is_empty()).then(|| code.to_string()),
4169 (!state.is_empty()).then(|| state.to_string()),
4170 );
4171 }
4172
4173 (Some(value.to_string()), None)
4174}
4175
4176fn lock_file(file: File, timeout: Duration) -> Result<LockedFile> {
4177 let start = Instant::now();
4178 let mut attempt: u32 = 0;
4179 loop {
4180 match FileExt::try_lock_exclusive(&file) {
4181 Ok(true) => return Ok(LockedFile { file }),
4182 Ok(false) => {} Err(e) => {
4184 return Err(Error::auth(format!("Failed to lock auth file: {e}")));
4185 }
4186 }
4187
4188 if start.elapsed() >= timeout {
4189 return Err(Error::auth("Timed out waiting for auth lock".to_string()));
4190 }
4191
4192 let base_ms: u64 = 10;
4193 let cap_ms: u64 = 500;
4194 let sleep_ms = base_ms
4195 .checked_shl(attempt.min(5))
4196 .unwrap_or(cap_ms)
4197 .min(cap_ms);
4198 let jitter = u64::from(start.elapsed().subsec_nanos()) % (sleep_ms / 2 + 1);
4199 let delay = sleep_ms / 2 + jitter;
4200 std::thread::sleep(Duration::from_millis(delay));
4201 attempt = attempt.saturating_add(1);
4202 }
4203}
4204
4205fn lock_file_shared(file: File, timeout: Duration) -> Result<LockedFile> {
4206 let start = Instant::now();
4207 let mut attempt: u32 = 0;
4208 loop {
4209 match FileExt::try_lock_shared(&file) {
4210 Ok(true) => return Ok(LockedFile { file }),
4211 Ok(false) => {} Err(e) => {
4213 return Err(Error::auth(format!("Failed to shared-lock auth file: {e}")));
4214 }
4215 }
4216
4217 if start.elapsed() >= timeout {
4218 return Err(Error::auth("Timed out waiting for auth lock".to_string()));
4219 }
4220
4221 let base_ms: u64 = 10;
4222 let cap_ms: u64 = 500;
4223 let sleep_ms = base_ms
4224 .checked_shl(attempt.min(5))
4225 .unwrap_or(cap_ms)
4226 .min(cap_ms);
4227 let jitter = u64::from(start.elapsed().subsec_nanos()) % (sleep_ms / 2 + 1);
4228 let delay = sleep_ms / 2 + jitter;
4229 std::thread::sleep(Duration::from_millis(delay));
4230 attempt = attempt.saturating_add(1);
4231 }
4232}
4233
4234struct LockedFile {
4236 file: File,
4237}
4238
4239impl LockedFile {
4240 const fn as_file_mut(&mut self) -> &mut File {
4241 &mut self.file
4242 }
4243}
4244
4245impl Drop for LockedFile {
4246 fn drop(&mut self) {
4247 let _ = FileExt::unlock(&self.file);
4248 }
4249}
4250
4251pub fn load_default_auth(path: &Path) -> Result<AuthStorage> {
4253 AuthStorage::load(path.to_path_buf())
4254}
4255
4256#[cfg(test)]
4257mod tests {
4258 use super::*;
4259 use std::io::{Read, Write};
4260 use std::net::TcpListener;
4261 use std::time::Duration;
4262
4263 fn next_token() -> String {
4264 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
4265 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
4266 .to_string()
4267 }
4268
4269 #[test]
4270 fn test_load_async_reads_existing_auth_file() {
4271 let temp = tempfile::tempdir().expect("tempdir");
4272 let path = temp.path().join("auth.json");
4273 std::fs::write(
4274 &path,
4275 r#"{
4276 "openai": {
4277 "type": "api_key",
4278 "key": "sk-test"
4279 }
4280}"#,
4281 )
4282 .expect("write auth file");
4283
4284 let rt = asupersync::runtime::RuntimeBuilder::current_thread()
4285 .build()
4286 .expect("runtime");
4287 let storage = rt
4288 .block_on(AuthStorage::load_async(path.clone()))
4289 .expect("load async");
4290
4291 assert_eq!(storage.path, path);
4292 assert_eq!(storage.api_key("openai").as_deref(), Some("sk-test"));
4293 }
4294
4295 #[test]
4296 fn test_save_async_persists_auth_file() {
4297 let temp = tempfile::tempdir().expect("tempdir");
4298 let path = temp.path().join("auth.json");
4299 let mut storage = AuthStorage::load(path.clone()).expect("load empty auth storage");
4300 storage.set(
4301 "anthropic",
4302 AuthCredential::ApiKey {
4303 key: "sk-ant".to_string(),
4304 },
4305 );
4306
4307 let rt = asupersync::runtime::RuntimeBuilder::current_thread()
4308 .build()
4309 .expect("runtime");
4310 rt.block_on(storage.save_async()).expect("save async");
4311
4312 let saved = AuthStorage::load(path).expect("reload auth storage");
4313 assert_eq!(saved.api_key("anthropic").as_deref(), Some("sk-ant"));
4314 }
4315
4316 #[allow(clippy::needless_pass_by_value)]
4317 fn log_test_event(test_name: &str, event: &str, data: serde_json::Value) {
4318 let timestamp_ms = std::time::SystemTime::now()
4319 .duration_since(std::time::UNIX_EPOCH)
4320 .expect("clock should be after epoch")
4321 .as_millis();
4322 let entry = serde_json::json!({
4323 "schema": "pi.test.auth_event.v1",
4324 "test": test_name,
4325 "event": event,
4326 "timestamp_ms": timestamp_ms,
4327 "data": data,
4328 });
4329 eprintln!(
4330 "JSONL: {}",
4331 serde_json::to_string(&entry).expect("serialize auth test event")
4332 );
4333 }
4334
4335 fn spawn_json_server(status_code: u16, body: &str) -> String {
4336 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
4337 let addr = listener.local_addr().expect("local addr");
4338 let body = body.to_string();
4339
4340 std::thread::spawn(move || {
4341 let (mut socket, _) = listener.accept().expect("accept");
4342 socket
4343 .set_read_timeout(Some(Duration::from_secs(2)))
4344 .expect("set read timeout");
4345
4346 let mut chunk = [0_u8; 4096];
4347 let _ = socket.read(&mut chunk);
4348
4349 let reason = match status_code {
4350 401 => "Unauthorized",
4351 500 => "Internal Server Error",
4352 _ => "OK",
4353 };
4354 let response = format!(
4355 "HTTP/1.1 {status_code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
4356 body.len()
4357 );
4358 socket
4359 .write_all(response.as_bytes())
4360 .expect("write response");
4361 socket.flush().expect("flush response");
4362 });
4363
4364 format!("http://{addr}/token")
4365 }
4366
4367 fn spawn_oauth_host_server(status_code: u16, body: &str) -> String {
4368 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
4369 let addr = listener.local_addr().expect("local addr");
4370 let body = body.to_string();
4371
4372 std::thread::spawn(move || {
4373 let (mut socket, _) = listener.accept().expect("accept");
4374 socket
4375 .set_read_timeout(Some(Duration::from_secs(2)))
4376 .expect("set read timeout");
4377
4378 let mut chunk = [0_u8; 4096];
4379 let _ = socket.read(&mut chunk);
4380
4381 let reason = match status_code {
4382 400 => "Bad Request",
4383 401 => "Unauthorized",
4384 403 => "Forbidden",
4385 500 => "Internal Server Error",
4386 _ => "OK",
4387 };
4388 let response = format!(
4389 "HTTP/1.1 {status_code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
4390 body.len()
4391 );
4392 socket
4393 .write_all(response.as_bytes())
4394 .expect("write response");
4395 socket.flush().expect("flush response");
4396 });
4397
4398 format!("http://{addr}")
4399 }
4400
4401 #[test]
4402 fn test_google_project_id_from_gcloud_config_parses_core_project() {
4403 let dir = tempfile::tempdir().expect("tmpdir");
4404 let gcloud_dir = dir.path().join("gcloud");
4405 let configs_dir = gcloud_dir.join("configurations");
4406 std::fs::create_dir_all(&configs_dir).expect("mkdir configurations");
4407 std::fs::write(
4408 configs_dir.join("config_default"),
4409 "[core]\nproject = my-proj\n",
4410 )
4411 .expect("write config_default");
4412
4413 let project = google_project_id_from_gcloud_config_with_env_lookup(|key| match key {
4414 "CLOUDSDK_CONFIG" => Some(gcloud_dir.to_string_lossy().to_string()),
4415 _ => None,
4416 });
4417
4418 assert_eq!(project.as_deref(), Some("my-proj"));
4419 }
4420
4421 #[test]
4422 fn test_auth_storage_load_missing_file_starts_empty() {
4423 let dir = tempfile::tempdir().expect("tmpdir");
4424 let auth_path = dir.path().join("missing-auth.json");
4425 assert!(!auth_path.exists());
4426
4427 let loaded = AuthStorage::load(auth_path.clone()).expect("load");
4428 assert!(loaded.entries.is_empty());
4429 assert_eq!(loaded.path, auth_path);
4430 }
4431
4432 #[test]
4433 fn test_auth_storage_api_key_round_trip() {
4434 let dir = tempfile::tempdir().expect("tmpdir");
4435 let auth_path = dir.path().join("auth.json");
4436
4437 {
4438 let mut auth = AuthStorage {
4439 path: auth_path.clone(),
4440 entries: HashMap::new(),
4441 };
4442 auth.set(
4443 "openai",
4444 AuthCredential::ApiKey {
4445 key: "stored-openai-key".to_string(),
4446 },
4447 );
4448 auth.save().expect("save");
4449 }
4450
4451 let loaded = AuthStorage::load(auth_path).expect("load");
4452 assert_eq!(
4453 loaded.api_key("openai").as_deref(),
4454 Some("stored-openai-key")
4455 );
4456 }
4457
4458 #[test]
4459 fn test_openai_oauth_url_generation() {
4460 let test_name = "test_openai_oauth_url_generation";
4461 log_test_event(
4462 test_name,
4463 "test_start",
4464 serde_json::json!({ "provider": "openai", "mode": "api_key" }),
4465 );
4466
4467 let env_keys = env_keys_for_provider("openai");
4468 assert!(
4469 env_keys.contains(&"OPENAI_API_KEY"),
4470 "expected OPENAI_API_KEY in env key candidates"
4471 );
4472 log_test_event(
4473 test_name,
4474 "url_generated",
4475 serde_json::json!({
4476 "provider": "openai",
4477 "flow_type": "api_key",
4478 "env_keys": env_keys,
4479 }),
4480 );
4481 log_test_event(
4482 test_name,
4483 "test_end",
4484 serde_json::json!({ "status": "pass" }),
4485 );
4486 }
4487
4488 #[test]
4489 fn test_openai_token_exchange() {
4490 let test_name = "test_openai_token_exchange";
4491 log_test_event(
4492 test_name,
4493 "test_start",
4494 serde_json::json!({ "provider": "openai", "mode": "api_key_storage" }),
4495 );
4496
4497 let dir = tempfile::tempdir().expect("tmpdir");
4498 let auth_path = dir.path().join("auth.json");
4499 let mut auth = AuthStorage::load(auth_path.clone()).expect("load auth");
4500 auth.set(
4501 "openai",
4502 AuthCredential::ApiKey {
4503 key: "openai-key-test".to_string(),
4504 },
4505 );
4506 auth.save().expect("save auth");
4507
4508 let reloaded = AuthStorage::load(auth_path).expect("reload auth");
4509 assert_eq!(
4510 reloaded.api_key("openai").as_deref(),
4511 Some("openai-key-test")
4512 );
4513 log_test_event(
4514 test_name,
4515 "token_exchanged",
4516 serde_json::json!({
4517 "provider": "openai",
4518 "flow_type": "api_key",
4519 "persisted": true,
4520 }),
4521 );
4522 log_test_event(
4523 test_name,
4524 "test_end",
4525 serde_json::json!({ "status": "pass" }),
4526 );
4527 }
4528
4529 #[test]
4530 fn test_google_oauth_url_generation() {
4531 let test_name = "test_google_oauth_url_generation";
4532 log_test_event(
4533 test_name,
4534 "test_start",
4535 serde_json::json!({ "provider": "google", "mode": "api_key" }),
4536 );
4537
4538 let env_keys = env_keys_for_provider("google");
4539 assert!(
4540 env_keys.contains(&"GOOGLE_API_KEY"),
4541 "expected GOOGLE_API_KEY in env key candidates"
4542 );
4543 assert!(
4544 env_keys.contains(&"GEMINI_API_KEY"),
4545 "expected GEMINI_API_KEY alias in env key candidates"
4546 );
4547 log_test_event(
4548 test_name,
4549 "url_generated",
4550 serde_json::json!({
4551 "provider": "google",
4552 "flow_type": "api_key",
4553 "env_keys": env_keys,
4554 }),
4555 );
4556 log_test_event(
4557 test_name,
4558 "test_end",
4559 serde_json::json!({ "status": "pass" }),
4560 );
4561 }
4562
4563 #[test]
4564 fn test_google_token_exchange() {
4565 let test_name = "test_google_token_exchange";
4566 log_test_event(
4567 test_name,
4568 "test_start",
4569 serde_json::json!({ "provider": "google", "mode": "api_key_storage" }),
4570 );
4571
4572 let dir = tempfile::tempdir().expect("tmpdir");
4573 let auth_path = dir.path().join("auth.json");
4574 let mut auth = AuthStorage::load(auth_path.clone()).expect("load auth");
4575 auth.set(
4576 "google",
4577 AuthCredential::ApiKey {
4578 key: "google-key-test".to_string(),
4579 },
4580 );
4581 auth.save().expect("save auth");
4582
4583 let reloaded = AuthStorage::load(auth_path).expect("reload auth");
4584 assert_eq!(
4585 reloaded.api_key("google").as_deref(),
4586 Some("google-key-test")
4587 );
4588 assert_eq!(
4589 reloaded
4590 .resolve_api_key_with_env_lookup("gemini", None, |_| None)
4591 .as_deref(),
4592 Some("google-key-test")
4593 );
4594 log_test_event(
4595 test_name,
4596 "token_exchanged",
4597 serde_json::json!({
4598 "provider": "google",
4599 "flow_type": "api_key",
4600 "has_refresh": false,
4601 }),
4602 );
4603 log_test_event(
4604 test_name,
4605 "test_end",
4606 serde_json::json!({ "status": "pass" }),
4607 );
4608 }
4609
4610 #[test]
4611 fn test_resolve_api_key_precedence_override_env_stored() {
4612 let dir = tempfile::tempdir().expect("tmpdir");
4613 let auth_path = dir.path().join("auth.json");
4614 let mut auth = AuthStorage {
4615 path: auth_path,
4616 entries: HashMap::new(),
4617 };
4618 auth.set(
4619 "openai",
4620 AuthCredential::ApiKey {
4621 key: "stored-openai-key".to_string(),
4622 },
4623 );
4624
4625 let env_value = "env-openai-key".to_string();
4626
4627 let override_resolved =
4628 auth.resolve_api_key_with_env_lookup("openai", Some("override-key"), |_| {
4629 Some(env_value.clone())
4630 });
4631 assert_eq!(override_resolved.as_deref(), Some("override-key"));
4632
4633 let env_resolved =
4634 auth.resolve_api_key_with_env_lookup("openai", None, |_| Some(env_value.clone()));
4635 assert_eq!(env_resolved.as_deref(), Some("env-openai-key"));
4636
4637 let stored_resolved = auth.resolve_api_key_with_env_lookup("openai", None, |_| None);
4638 assert_eq!(stored_resolved.as_deref(), Some("stored-openai-key"));
4639 }
4640
4641 #[test]
4642 fn test_resolve_api_key_prefers_stored_oauth_over_env() {
4643 let dir = tempfile::tempdir().expect("tmpdir");
4644 let auth_path = dir.path().join("auth.json");
4645 let mut auth = AuthStorage {
4646 path: auth_path,
4647 entries: HashMap::new(),
4648 };
4649 let now = chrono::Utc::now().timestamp_millis();
4650 auth.set(
4651 "anthropic",
4652 AuthCredential::OAuth {
4653 access_token: "stored-oauth-token".to_string(),
4654 refresh_token: "refresh-token".to_string(),
4655 expires: now + 60_000,
4656 token_url: None,
4657 client_id: None,
4658 },
4659 );
4660
4661 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", None, |_| {
4662 Some("env-api-key".to_string())
4663 });
4664 let token = resolved.expect("resolved anthropic oauth token");
4665 assert_eq!(
4666 unmark_anthropic_oauth_bearer_token(&token),
4667 Some("stored-oauth-token")
4668 );
4669 }
4670
4671 #[test]
4672 fn test_resolve_api_key_expired_oauth_falls_back_to_env() {
4673 let dir = tempfile::tempdir().expect("tmpdir");
4674 let auth_path = dir.path().join("auth.json");
4675 let mut auth = AuthStorage {
4676 path: auth_path,
4677 entries: HashMap::new(),
4678 };
4679 let now = chrono::Utc::now().timestamp_millis();
4680 auth.set(
4681 "anthropic",
4682 AuthCredential::OAuth {
4683 access_token: "expired-oauth-token".to_string(),
4684 refresh_token: "refresh-token".to_string(),
4685 expires: now - 1_000,
4686 token_url: None,
4687 client_id: None,
4688 },
4689 );
4690
4691 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", None, |_| {
4692 Some("env-api-key".to_string())
4693 });
4694 assert_eq!(resolved.as_deref(), Some("env-api-key"));
4695 }
4696
4697 #[test]
4698 fn test_resolve_api_key_returns_none_when_unconfigured() {
4699 let dir = tempfile::tempdir().expect("tmpdir");
4700 let auth_path = dir.path().join("auth.json");
4701 let auth = AuthStorage {
4702 path: auth_path,
4703 entries: HashMap::new(),
4704 };
4705
4706 let resolved =
4707 auth.resolve_api_key_with_env_lookup("nonexistent-provider-for-test", None, |_| None);
4708 assert!(resolved.is_none());
4709 }
4710
4711 #[test]
4712 fn test_generate_pkce_is_base64url_no_pad() {
4713 let (verifier, challenge) = generate_pkce();
4714 assert!(!verifier.is_empty());
4715 assert!(!challenge.is_empty());
4716 assert!(!verifier.contains('+'));
4717 assert!(!verifier.contains('/'));
4718 assert!(!verifier.contains('='));
4719 assert!(!challenge.contains('+'));
4720 assert!(!challenge.contains('/'));
4721 assert!(!challenge.contains('='));
4722 assert_eq!(verifier.len(), 43);
4723 assert_eq!(challenge.len(), 43);
4724 }
4725
4726 #[test]
4727 fn test_start_anthropic_oauth_url_contains_required_params() {
4728 let info = start_anthropic_oauth().expect("start");
4729 let (base, query) = info.url.split_once('?').expect("missing query");
4730 assert_eq!(base, ANTHROPIC_OAUTH_AUTHORIZE_URL);
4731
4732 let params: std::collections::HashMap<_, _> =
4733 parse_query_pairs(query).into_iter().collect();
4734 assert_eq!(
4735 params.get("client_id").map(String::as_str),
4736 Some(ANTHROPIC_OAUTH_CLIENT_ID)
4737 );
4738 assert_eq!(
4739 params.get("response_type").map(String::as_str),
4740 Some("code")
4741 );
4742 assert_eq!(
4743 params.get("redirect_uri").map(String::as_str),
4744 Some(ANTHROPIC_OAUTH_REDIRECT_URI)
4745 );
4746 assert_eq!(
4747 params.get("scope").map(String::as_str),
4748 Some(ANTHROPIC_OAUTH_SCOPES)
4749 );
4750 assert_eq!(
4751 params.get("code_challenge_method").map(String::as_str),
4752 Some("S256")
4753 );
4754 assert_eq!(
4755 params.get("state").map(String::as_str),
4756 Some(info.verifier.as_str())
4757 );
4758 assert!(params.contains_key("code_challenge"));
4759 }
4760
4761 #[test]
4762 fn test_parse_oauth_code_input_accepts_url_and_hash_formats() {
4763 let (code, state) = parse_oauth_code_input(
4764 "https://console.anthropic.com/oauth/code/callback?code=abc&state=def",
4765 );
4766 assert_eq!(code.as_deref(), Some("abc"));
4767 assert_eq!(state.as_deref(), Some("def"));
4768
4769 let (code, state) = parse_oauth_code_input("abc#def");
4770 assert_eq!(code.as_deref(), Some("abc"));
4771 assert_eq!(state.as_deref(), Some("def"));
4772
4773 let (code, state) = parse_oauth_code_input("abc");
4774 assert_eq!(code.as_deref(), Some("abc"));
4775 assert!(state.is_none());
4776 }
4777
4778 #[test]
4779 fn test_complete_anthropic_oauth_rejects_state_mismatch() {
4780 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4781 rt.expect("runtime").block_on(async {
4782 let err = complete_anthropic_oauth("abc#mismatch", "expected")
4783 .await
4784 .unwrap_err();
4785 assert!(err.to_string().contains("State mismatch"));
4786 });
4787 }
4788
4789 fn sample_oauth_config() -> crate::models::OAuthConfig {
4790 crate::models::OAuthConfig {
4791 auth_url: "https://auth.example.com/authorize".to_string(),
4792 token_url: "https://auth.example.com/token".to_string(),
4793 client_id: "ext-client-123".to_string(),
4794 scopes: vec!["read".to_string(), "write".to_string()],
4795 redirect_uri: Some("http://localhost:9876/callback".to_string()),
4796 }
4797 }
4798
4799 #[test]
4800 fn test_start_extension_oauth_url_contains_required_params() {
4801 let config = sample_oauth_config();
4802 let info = start_extension_oauth("my-ext-provider", &config).expect("start");
4803
4804 assert_eq!(info.provider, "my-ext-provider");
4805 assert!(!info.verifier.is_empty());
4806
4807 let (base, query) = info.url.split_once('?').expect("missing query");
4808 assert_eq!(base, "https://auth.example.com/authorize");
4809
4810 let params: std::collections::HashMap<_, _> =
4811 parse_query_pairs(query).into_iter().collect();
4812 assert_eq!(
4813 params.get("client_id").map(String::as_str),
4814 Some("ext-client-123")
4815 );
4816 assert_eq!(
4817 params.get("response_type").map(String::as_str),
4818 Some("code")
4819 );
4820 assert_eq!(
4821 params.get("redirect_uri").map(String::as_str),
4822 Some("http://localhost:9876/callback")
4823 );
4824 assert_eq!(params.get("scope").map(String::as_str), Some("read write"));
4825 assert_eq!(
4826 params.get("code_challenge_method").map(String::as_str),
4827 Some("S256")
4828 );
4829 assert_eq!(
4830 params.get("state").map(String::as_str),
4831 Some(info.verifier.as_str())
4832 );
4833 assert!(params.contains_key("code_challenge"));
4834 }
4835
4836 #[test]
4837 fn test_start_extension_oauth_no_redirect_uri() {
4838 let config = crate::models::OAuthConfig {
4839 auth_url: "https://auth.example.com/authorize".to_string(),
4840 token_url: "https://auth.example.com/token".to_string(),
4841 client_id: "ext-client-123".to_string(),
4842 scopes: vec!["read".to_string()],
4843 redirect_uri: None,
4844 };
4845 let info = start_extension_oauth("no-redirect", &config).expect("start");
4846
4847 let (_, query) = info.url.split_once('?').expect("missing query");
4848 let params: std::collections::HashMap<_, _> =
4849 parse_query_pairs(query).into_iter().collect();
4850 assert!(!params.contains_key("redirect_uri"));
4851 }
4852
4853 #[test]
4854 fn test_start_extension_oauth_empty_scopes() {
4855 let config = crate::models::OAuthConfig {
4856 auth_url: "https://auth.example.com/authorize".to_string(),
4857 token_url: "https://auth.example.com/token".to_string(),
4858 client_id: "ext-client-123".to_string(),
4859 scopes: vec![],
4860 redirect_uri: None,
4861 };
4862 let info = start_extension_oauth("empty-scopes", &config).expect("start");
4863
4864 let (_, query) = info.url.split_once('?').expect("missing query");
4865 let params: std::collections::HashMap<_, _> =
4866 parse_query_pairs(query).into_iter().collect();
4867 assert_eq!(params.get("scope").map(String::as_str), Some(""));
4869 }
4870
4871 #[test]
4872 fn test_start_extension_oauth_pkce_format() {
4873 let config = sample_oauth_config();
4874 let info = start_extension_oauth("pkce-test", &config).expect("start");
4875
4876 assert!(!info.verifier.contains('+'));
4878 assert!(!info.verifier.contains('/'));
4879 assert!(!info.verifier.contains('='));
4880 assert_eq!(info.verifier.len(), 43);
4881 }
4882
4883 #[test]
4884 fn test_complete_extension_oauth_rejects_state_mismatch() {
4885 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4886 rt.expect("runtime").block_on(async {
4887 let config = sample_oauth_config();
4888 let err = complete_extension_oauth(&config, "abc#mismatch", "expected")
4889 .await
4890 .unwrap_err();
4891 assert!(err.to_string().contains("State mismatch"));
4892 });
4893 }
4894
4895 #[test]
4896 fn test_complete_copilot_browser_oauth_rejects_state_mismatch() {
4897 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4898 rt.expect("runtime").block_on(async {
4899 let config = CopilotOAuthConfig::default();
4900 let err = complete_copilot_browser_oauth(&config, "abc#mismatch", "expected", None)
4901 .await
4902 .unwrap_err();
4903 assert!(err.to_string().contains("State mismatch"));
4904 });
4905 }
4906
4907 #[test]
4908 fn test_complete_gitlab_oauth_rejects_state_mismatch() {
4909 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4910 rt.expect("runtime").block_on(async {
4911 let config = GitLabOAuthConfig::default();
4912 let err = complete_gitlab_oauth(&config, "abc#mismatch", "expected", None)
4913 .await
4914 .unwrap_err();
4915 assert!(err.to_string().contains("State mismatch"));
4916 });
4917 }
4918
4919 #[test]
4920 fn test_refresh_expired_extension_oauth_tokens_skips_anthropic() {
4921 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4923 rt.expect("runtime").block_on(async {
4924 let dir = tempfile::tempdir().expect("tmpdir");
4925 let auth_path = dir.path().join("auth.json");
4926 let mut auth = AuthStorage {
4927 path: auth_path,
4928 entries: HashMap::new(),
4929 };
4930 let initial_access = next_token();
4932 let initial_refresh = next_token();
4933 auth.entries.insert(
4934 "anthropic".to_string(),
4935 AuthCredential::OAuth {
4936 access_token: initial_access.clone(),
4937 refresh_token: initial_refresh,
4938 expires: 0, token_url: None,
4940 client_id: None,
4941 },
4942 );
4943
4944 let client = crate::http::client::Client::new();
4945 let mut extension_configs = HashMap::new();
4946 extension_configs.insert("anthropic".to_string(), sample_oauth_config());
4947
4948 let result = auth
4950 .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
4951 .await;
4952 assert!(result.is_ok());
4953
4954 assert!(
4956 matches!(
4957 auth.entries.get("anthropic"),
4958 Some(AuthCredential::OAuth { access_token, .. })
4959 if access_token == &initial_access
4960 ),
4961 "expected OAuth credential"
4962 );
4963 });
4964 }
4965
4966 #[test]
4967 fn test_refresh_expired_extension_oauth_tokens_skips_unexpired() {
4968 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
4969 rt.expect("runtime").block_on(async {
4970 let dir = tempfile::tempdir().expect("tmpdir");
4971 let auth_path = dir.path().join("auth.json");
4972 let mut auth = AuthStorage {
4973 path: auth_path,
4974 entries: HashMap::new(),
4975 };
4976 let initial_access_token = next_token();
4978 let initial_refresh_token = next_token();
4979 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
4980 auth.entries.insert(
4981 "my-ext".to_string(),
4982 AuthCredential::OAuth {
4983 access_token: initial_access_token.clone(),
4984 refresh_token: initial_refresh_token,
4985 expires: far_future,
4986 token_url: None,
4987 client_id: None,
4988 },
4989 );
4990
4991 let client = crate::http::client::Client::new();
4992 let mut extension_configs = HashMap::new();
4993 extension_configs.insert("my-ext".to_string(), sample_oauth_config());
4994
4995 let result = auth
4996 .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
4997 .await;
4998 assert!(result.is_ok());
4999
5000 assert!(
5002 matches!(
5003 auth.entries.get("my-ext"),
5004 Some(AuthCredential::OAuth { access_token, .. })
5005 if access_token == &initial_access_token
5006 ),
5007 "expected OAuth credential"
5008 );
5009 });
5010 }
5011
5012 #[test]
5013 fn test_refresh_expired_extension_oauth_tokens_skips_unknown_provider() {
5014 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5015 rt.expect("runtime").block_on(async {
5016 let dir = tempfile::tempdir().expect("tmpdir");
5017 let auth_path = dir.path().join("auth.json");
5018 let mut auth = AuthStorage {
5019 path: auth_path,
5020 entries: HashMap::new(),
5021 };
5022 let initial_access_token = next_token();
5024 let initial_refresh_token = next_token();
5025 auth.entries.insert(
5026 "unknown-ext".to_string(),
5027 AuthCredential::OAuth {
5028 access_token: initial_access_token.clone(),
5029 refresh_token: initial_refresh_token,
5030 expires: 0,
5031 token_url: None,
5032 client_id: None,
5033 },
5034 );
5035
5036 let client = crate::http::client::Client::new();
5037 let extension_configs = HashMap::new(); let result = auth
5040 .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
5041 .await;
5042 assert!(result.is_ok());
5043
5044 assert!(
5046 matches!(
5047 auth.entries.get("unknown-ext"),
5048 Some(AuthCredential::OAuth { access_token, .. })
5049 if access_token == &initial_access_token
5050 ),
5051 "expected OAuth credential"
5052 );
5053 });
5054 }
5055
5056 #[test]
5057 #[cfg(unix)]
5058 fn test_refresh_expired_extension_oauth_tokens_updates_and_persists() {
5059 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5060 rt.expect("runtime").block_on(async {
5061 let dir = tempfile::tempdir().expect("tmpdir");
5062 let auth_path = dir.path().join("auth.json");
5063 let mut auth = AuthStorage {
5064 path: auth_path.clone(),
5065 entries: HashMap::new(),
5066 };
5067 auth.entries.insert(
5068 "my-ext".to_string(),
5069 AuthCredential::OAuth {
5070 access_token: "old-access".to_string(),
5071 refresh_token: "old-refresh".to_string(),
5072 expires: 0,
5073 token_url: None,
5074 client_id: None,
5075 },
5076 );
5077
5078 let token_url = spawn_json_server(
5079 200,
5080 r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}"#,
5081 );
5082 let mut config = sample_oauth_config();
5083 config.token_url = token_url;
5084
5085 let mut extension_configs = HashMap::new();
5086 extension_configs.insert("my-ext".to_string(), config);
5087
5088 let client = crate::http::client::Client::new();
5089 auth.refresh_expired_extension_oauth_tokens(&client, &extension_configs)
5090 .await
5091 .expect("refresh");
5092
5093 let now = chrono::Utc::now().timestamp_millis();
5094 match auth.entries.get("my-ext").expect("credential updated") {
5095 AuthCredential::OAuth {
5096 access_token,
5097 refresh_token,
5098 expires,
5099 ..
5100 } => {
5101 assert_eq!(access_token, "new-access");
5102 assert_eq!(refresh_token, "new-refresh");
5103 assert!(*expires > now);
5104 }
5105 other => {
5106 unreachable!("expected oauth credential, got: {other:?}");
5107 }
5108 }
5109
5110 let reloaded = AuthStorage::load(auth_path).expect("reload");
5111 match reloaded.get("my-ext").expect("persisted credential") {
5112 AuthCredential::OAuth {
5113 access_token,
5114 refresh_token,
5115 ..
5116 } => {
5117 assert_eq!(access_token, "new-access");
5118 assert_eq!(refresh_token, "new-refresh");
5119 }
5120 other => {
5121 unreachable!("expected oauth credential, got: {other:?}");
5122 }
5123 }
5124 });
5125 }
5126
5127 #[test]
5128 #[cfg(unix)]
5129 fn test_refresh_extension_oauth_token_redacts_secret_in_error() {
5130 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
5131 rt.expect("runtime").block_on(async {
5132 let refresh_secret = "secret-refresh-token-123";
5133 let leaked_access = "leaked-access-token-456";
5134 let token_url = spawn_json_server(
5135 401,
5136 &format!(
5137 r#"{{"error":"invalid_grant","echo":"{refresh_secret}","access_token":"{leaked_access}"}}"#
5138 ),
5139 );
5140
5141 let mut config = sample_oauth_config();
5142 config.token_url = token_url;
5143
5144 let client = crate::http::client::Client::new();
5145 let err = refresh_extension_oauth_token(&client, &config, refresh_secret)
5146 .await
5147 .expect_err("expected refresh failure");
5148 let err_text = err.to_string();
5149
5150 assert!(
5151 err_text.contains("[REDACTED]"),
5152 "expected redacted marker in error: {err_text}"
5153 );
5154 assert!(
5155 !err_text.contains(refresh_secret),
5156 "refresh token leaked in error: {err_text}"
5157 );
5158 assert!(
5159 !err_text.contains(leaked_access),
5160 "access token leaked in error: {err_text}"
5161 );
5162 });
5163 }
5164
5165 #[test]
5166 fn test_refresh_failure_produces_recovery_action() {
5167 let test_name = "test_refresh_failure_produces_recovery_action";
5168 log_test_event(
5169 test_name,
5170 "test_start",
5171 serde_json::json!({ "provider": "anthropic" }),
5172 );
5173
5174 let err = crate::error::Error::auth("OAuth token refresh failed: invalid_grant");
5175 let hints = err.hints();
5176 assert!(
5177 hints.hints.iter().any(|hint| hint.contains("login")),
5178 "expected auth hints to include login guidance, got {:?}",
5179 hints.hints
5180 );
5181 log_test_event(
5182 test_name,
5183 "refresh_failed",
5184 serde_json::json!({
5185 "provider": "anthropic",
5186 "error_type": "invalid_grant",
5187 "recovery": hints.hints,
5188 }),
5189 );
5190 log_test_event(
5191 test_name,
5192 "test_end",
5193 serde_json::json!({ "status": "pass" }),
5194 );
5195 }
5196
5197 #[test]
5198 fn test_refresh_failure_network_vs_auth_different_messages() {
5199 let test_name = "test_refresh_failure_network_vs_auth_different_messages";
5200 log_test_event(
5201 test_name,
5202 "test_start",
5203 serde_json::json!({ "scenario": "compare provider-network vs auth-refresh hints" }),
5204 );
5205
5206 let auth_err = crate::error::Error::auth("OAuth token refresh failed: invalid_grant");
5207 let auth_hints = auth_err.hints();
5208 let network_err = crate::error::Error::provider(
5209 "anthropic",
5210 "Network connection error: connection reset by peer",
5211 );
5212 let network_hints = network_err.hints();
5213
5214 assert!(
5215 auth_hints.hints.iter().any(|hint| hint.contains("login")),
5216 "expected auth-refresh hints to include login guidance, got {:?}",
5217 auth_hints.hints
5218 );
5219 assert!(
5220 network_hints.hints.iter().any(|hint| {
5221 let normalized = hint.to_ascii_lowercase();
5222 normalized.contains("network") || normalized.contains("connection")
5223 }),
5224 "expected network hints to mention network/connection checks, got {:?}",
5225 network_hints.hints
5226 );
5227 log_test_event(
5228 test_name,
5229 "error_classified",
5230 serde_json::json!({
5231 "auth_hints": auth_hints.hints,
5232 "network_hints": network_hints.hints,
5233 }),
5234 );
5235 log_test_event(
5236 test_name,
5237 "test_end",
5238 serde_json::json!({ "status": "pass" }),
5239 );
5240 }
5241
5242 #[test]
5243 fn test_oauth_token_storage_round_trip() {
5244 let dir = tempfile::tempdir().expect("tmpdir");
5245 let auth_path = dir.path().join("auth.json");
5246 let expected_access_token = next_token();
5247 let expected_refresh_token = next_token();
5248
5249 {
5251 let mut auth = AuthStorage {
5252 path: auth_path.clone(),
5253 entries: HashMap::new(),
5254 };
5255 auth.set(
5256 "ext-provider",
5257 AuthCredential::OAuth {
5258 access_token: expected_access_token.clone(),
5259 refresh_token: expected_refresh_token.clone(),
5260 expires: 9_999_999_999_000,
5261 token_url: None,
5262 client_id: None,
5263 },
5264 );
5265 auth.save().expect("save");
5266 }
5267
5268 let loaded = AuthStorage::load(auth_path).expect("load");
5270 let cred = loaded.get("ext-provider").expect("credential present");
5271 match cred {
5272 AuthCredential::OAuth {
5273 access_token,
5274 refresh_token,
5275 expires,
5276 ..
5277 } => {
5278 assert_eq!(access_token, &expected_access_token);
5279 assert_eq!(refresh_token, &expected_refresh_token);
5280 assert_eq!(*expires, 9_999_999_999_000);
5281 }
5282 other => {
5283 unreachable!("expected OAuth credential, got: {other:?}");
5284 }
5285 }
5286 }
5287
5288 #[test]
5289 fn test_oauth_api_key_returns_access_token_when_unexpired() {
5290 let dir = tempfile::tempdir().expect("tmpdir");
5291 let auth_path = dir.path().join("auth.json");
5292 let expected_access_token = next_token();
5293 let expected_refresh_token = next_token();
5294 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
5295 let mut auth = AuthStorage {
5296 path: auth_path,
5297 entries: HashMap::new(),
5298 };
5299 auth.set(
5300 "ext-provider",
5301 AuthCredential::OAuth {
5302 access_token: expected_access_token.clone(),
5303 refresh_token: expected_refresh_token,
5304 expires: far_future,
5305 token_url: None,
5306 client_id: None,
5307 },
5308 );
5309
5310 assert_eq!(
5311 auth.api_key("ext-provider").as_deref(),
5312 Some(expected_access_token.as_str())
5313 );
5314 }
5315
5316 #[test]
5317 fn test_oauth_api_key_returns_none_when_expired() {
5318 let dir = tempfile::tempdir().expect("tmpdir");
5319 let auth_path = dir.path().join("auth.json");
5320 let expected_access_token = next_token();
5321 let expected_refresh_token = next_token();
5322 let mut auth = AuthStorage {
5323 path: auth_path,
5324 entries: HashMap::new(),
5325 };
5326 auth.set(
5327 "ext-provider",
5328 AuthCredential::OAuth {
5329 access_token: expected_access_token,
5330 refresh_token: expected_refresh_token,
5331 expires: 0, token_url: None,
5333 client_id: None,
5334 },
5335 );
5336
5337 assert_eq!(auth.api_key("ext-provider"), None);
5338 }
5339
5340 #[test]
5341 fn test_credential_status_reports_oauth_valid_and_expired() {
5342 let dir = tempfile::tempdir().expect("tmpdir");
5343 let auth_path = dir.path().join("auth.json");
5344 let now = chrono::Utc::now().timestamp_millis();
5345
5346 let mut auth = AuthStorage {
5347 path: auth_path,
5348 entries: HashMap::new(),
5349 };
5350 auth.set(
5351 "valid-oauth",
5352 AuthCredential::OAuth {
5353 access_token: "valid-access".to_string(),
5354 refresh_token: "valid-refresh".to_string(),
5355 expires: now + 30_000,
5356 token_url: None,
5357 client_id: None,
5358 },
5359 );
5360 auth.set(
5361 "expired-oauth",
5362 AuthCredential::OAuth {
5363 access_token: "expired-access".to_string(),
5364 refresh_token: "expired-refresh".to_string(),
5365 expires: now - 30_000,
5366 token_url: None,
5367 client_id: None,
5368 },
5369 );
5370
5371 match auth.credential_status("valid-oauth") {
5372 CredentialStatus::OAuthValid { expires_in_ms } => {
5373 assert!(expires_in_ms > 0, "expires_in_ms should be positive");
5374 log_test_event(
5375 "test_provider_listing_shows_expiry",
5376 "assertion",
5377 serde_json::json!({
5378 "provider": "valid-oauth",
5379 "status": "oauth_valid",
5380 "expires_in_ms": expires_in_ms,
5381 }),
5382 );
5383 }
5384 other => panic!(),
5385 }
5386
5387 match auth.credential_status("expired-oauth") {
5388 CredentialStatus::OAuthExpired { expired_by_ms } => {
5389 assert!(expired_by_ms > 0, "expired_by_ms should be positive");
5390 }
5391 other => panic!(),
5392 }
5393 }
5394
5395 #[test]
5396 fn test_credential_status_uses_alias_lookup() {
5397 let dir = tempfile::tempdir().expect("tmpdir");
5398 let auth_path = dir.path().join("auth.json");
5399 let mut auth = AuthStorage {
5400 path: auth_path,
5401 entries: HashMap::new(),
5402 };
5403 auth.set(
5404 "google",
5405 AuthCredential::ApiKey {
5406 key: "google-key".to_string(),
5407 },
5408 );
5409
5410 assert_eq!(auth.credential_status("gemini"), CredentialStatus::ApiKey);
5411 assert_eq!(
5412 auth.credential_status("missing-provider"),
5413 CredentialStatus::Missing
5414 );
5415 log_test_event(
5416 "test_provider_listing_shows_all_providers",
5417 "assertion",
5418 serde_json::json!({
5419 "providers_checked": ["google", "gemini", "missing-provider"],
5420 "google_status": "api_key",
5421 "missing_status": "missing",
5422 }),
5423 );
5424 log_test_event(
5425 "test_provider_listing_no_credentials",
5426 "assertion",
5427 serde_json::json!({
5428 "provider": "missing-provider",
5429 "status": "Not authenticated",
5430 }),
5431 );
5432 }
5433
5434 #[test]
5435 fn test_has_stored_credential_uses_reverse_alias_lookup() {
5436 let dir = tempfile::tempdir().expect("tmpdir");
5437 let auth_path = dir.path().join("auth.json");
5438 let mut auth = AuthStorage {
5439 path: auth_path,
5440 entries: HashMap::new(),
5441 };
5442 auth.set(
5443 "gemini",
5444 AuthCredential::ApiKey {
5445 key: "legacy-gemini-key".to_string(),
5446 },
5447 );
5448
5449 assert!(auth.has_stored_credential("google"));
5450 assert!(auth.has_stored_credential("gemini"));
5451 }
5452
5453 #[test]
5454 fn test_resolve_api_key_handles_case_insensitive_stored_provider_keys() {
5455 let dir = tempfile::tempdir().expect("tmpdir");
5456 let auth_path = dir.path().join("auth.json");
5457 let mut auth = AuthStorage {
5458 path: auth_path,
5459 entries: HashMap::new(),
5460 };
5461 auth.set(
5462 "Google",
5463 AuthCredential::ApiKey {
5464 key: "mixed-case-key".to_string(),
5465 },
5466 );
5467
5468 let resolved = auth.resolve_api_key_with_env_lookup("google", None, |_| None);
5469 assert_eq!(resolved.as_deref(), Some("mixed-case-key"));
5470 }
5471
5472 #[test]
5473 fn test_credential_status_uses_reverse_alias_lookup() {
5474 let dir = tempfile::tempdir().expect("tmpdir");
5475 let auth_path = dir.path().join("auth.json");
5476 let mut auth = AuthStorage {
5477 path: auth_path,
5478 entries: HashMap::new(),
5479 };
5480 auth.set(
5481 "gemini",
5482 AuthCredential::ApiKey {
5483 key: "legacy-gemini-key".to_string(),
5484 },
5485 );
5486
5487 assert_eq!(auth.credential_status("google"), CredentialStatus::ApiKey);
5488 }
5489
5490 #[test]
5491 fn test_remove_provider_aliases_removes_canonical_and_alias_entries() {
5492 let dir = tempfile::tempdir().expect("tmpdir");
5493 let auth_path = dir.path().join("auth.json");
5494 let mut auth = AuthStorage {
5495 path: auth_path,
5496 entries: HashMap::new(),
5497 };
5498 auth.set(
5499 "google",
5500 AuthCredential::ApiKey {
5501 key: "google-key".to_string(),
5502 },
5503 );
5504 auth.set(
5505 "gemini",
5506 AuthCredential::ApiKey {
5507 key: "gemini-key".to_string(),
5508 },
5509 );
5510
5511 assert!(auth.remove_provider_aliases("google"));
5512 assert!(!auth.has_stored_credential("google"));
5513 assert!(!auth.has_stored_credential("gemini"));
5514 }
5515
5516 #[test]
5517 fn test_auth_remove_credential() {
5518 let dir = tempfile::tempdir().expect("tmpdir");
5519 let auth_path = dir.path().join("auth.json");
5520 let mut auth = AuthStorage {
5521 path: auth_path,
5522 entries: HashMap::new(),
5523 };
5524 auth.set(
5525 "ext-provider",
5526 AuthCredential::ApiKey {
5527 key: "key-123".to_string(),
5528 },
5529 );
5530
5531 assert!(auth.get("ext-provider").is_some());
5532 assert!(auth.remove("ext-provider"));
5533 assert!(auth.get("ext-provider").is_none());
5534 assert!(!auth.remove("ext-provider")); }
5536
5537 #[test]
5538 fn test_auth_env_key_returns_none_for_extension_providers() {
5539 assert!(env_key_for_provider("my-ext-provider").is_none());
5541 assert!(env_key_for_provider("custom-llm").is_none());
5542 assert_eq!(env_key_for_provider("anthropic"), Some("ANTHROPIC_API_KEY"));
5544 assert_eq!(env_key_for_provider("openai"), Some("OPENAI_API_KEY"));
5545 }
5546
5547 #[test]
5548 fn test_extension_oauth_config_special_chars_in_scopes() {
5549 let config = crate::models::OAuthConfig {
5550 auth_url: "https://auth.example.com/authorize".to_string(),
5551 token_url: "https://auth.example.com/token".to_string(),
5552 client_id: "ext-client".to_string(),
5553 scopes: vec![
5554 "api:read".to_string(),
5555 "api:write".to_string(),
5556 "user:profile".to_string(),
5557 ],
5558 redirect_uri: None,
5559 };
5560 let info = start_extension_oauth("scoped", &config).expect("start");
5561
5562 let (_, query) = info.url.split_once('?').expect("missing query");
5563 let params: std::collections::HashMap<_, _> =
5564 parse_query_pairs(query).into_iter().collect();
5565 assert_eq!(
5566 params.get("scope").map(String::as_str),
5567 Some("api:read api:write user:profile")
5568 );
5569 }
5570
5571 #[test]
5572 fn test_extension_oauth_url_encodes_special_chars() {
5573 let config = crate::models::OAuthConfig {
5574 auth_url: "https://auth.example.com/authorize".to_string(),
5575 token_url: "https://auth.example.com/token".to_string(),
5576 client_id: "client with spaces".to_string(),
5577 scopes: vec!["scope&dangerous".to_string()],
5578 redirect_uri: Some("http://localhost:9876/call back".to_string()),
5579 };
5580 let info = start_extension_oauth("encoded", &config).expect("start");
5581
5582 assert!(info.url.contains("client%20with%20spaces"));
5584 assert!(info.url.contains("scope%26dangerous"));
5585 assert!(info.url.contains("call%20back"));
5586 }
5587
5588 #[test]
5591 fn test_auth_storage_load_valid_api_key() {
5592 let dir = tempfile::tempdir().expect("tmpdir");
5593 let auth_path = dir.path().join("auth.json");
5594 let content = r#"{"anthropic":{"type":"api_key","key":"sk-test-abc"}}"#;
5595 fs::write(&auth_path, content).expect("write");
5596
5597 let auth = AuthStorage::load(auth_path).expect("load");
5598 assert!(auth.entries.contains_key("anthropic"));
5599 match auth.get("anthropic").expect("credential") {
5600 AuthCredential::ApiKey { key } => assert_eq!(key, "sk-test-abc"),
5601 other => panic!(),
5602 }
5603 }
5604
5605 #[test]
5606 fn test_auth_storage_load_corrupted_json_returns_empty() {
5607 let dir = tempfile::tempdir().expect("tmpdir");
5608 let auth_path = dir.path().join("auth.json");
5609 fs::write(&auth_path, "not valid json {{").expect("write");
5610
5611 let auth = AuthStorage::load(auth_path).expect("load");
5612 assert!(auth.entries.is_empty());
5614 }
5615
5616 #[test]
5617 fn test_auth_storage_load_empty_file_returns_empty() {
5618 let dir = tempfile::tempdir().expect("tmpdir");
5619 let auth_path = dir.path().join("auth.json");
5620 fs::write(&auth_path, "").expect("write");
5621
5622 let auth = AuthStorage::load(auth_path).expect("load");
5623 assert!(auth.entries.is_empty());
5624 }
5625
5626 #[test]
5629 fn test_resolve_api_key_empty_override_falls_through() {
5630 let dir = tempfile::tempdir().expect("tmpdir");
5631 let auth_path = dir.path().join("auth.json");
5632 let mut auth = AuthStorage {
5633 path: auth_path,
5634 entries: HashMap::new(),
5635 };
5636 auth.set(
5637 "anthropic",
5638 AuthCredential::ApiKey {
5639 key: "stored-key".to_string(),
5640 },
5641 );
5642
5643 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", Some(""), |_| None);
5645 assert_eq!(resolved.as_deref(), Some("stored-key"));
5646 }
5647
5648 #[test]
5649 fn test_resolve_api_key_env_beats_stored() {
5650 let dir = tempfile::tempdir().expect("tmpdir");
5652 let auth_path = dir.path().join("auth.json");
5653 let mut auth = AuthStorage {
5654 path: auth_path,
5655 entries: HashMap::new(),
5656 };
5657 auth.set(
5658 "openai",
5659 AuthCredential::ApiKey {
5660 key: "stored-key".to_string(),
5661 },
5662 );
5663
5664 let resolved =
5665 auth.resolve_api_key_with_env_lookup("openai", None, |_| Some("env-key".to_string()));
5666 assert_eq!(
5667 resolved.as_deref(),
5668 Some("env-key"),
5669 "env should beat stored"
5670 );
5671 }
5672
5673 #[test]
5674 fn test_resolve_api_key_groq_env_beats_stored() {
5675 let dir = tempfile::tempdir().expect("tmpdir");
5676 let auth_path = dir.path().join("auth.json");
5677 let mut auth = AuthStorage {
5678 path: auth_path,
5679 entries: HashMap::new(),
5680 };
5681 auth.set(
5682 "groq",
5683 AuthCredential::ApiKey {
5684 key: "stored-groq-key".to_string(),
5685 },
5686 );
5687
5688 let resolved =
5689 auth.resolve_api_key_with_env_lookup("groq", None, |_| Some("env-groq-key".into()));
5690 assert_eq!(resolved.as_deref(), Some("env-groq-key"));
5691 }
5692
5693 #[test]
5694 fn test_resolve_api_key_openrouter_env_beats_stored() {
5695 let dir = tempfile::tempdir().expect("tmpdir");
5696 let auth_path = dir.path().join("auth.json");
5697 let mut auth = AuthStorage {
5698 path: auth_path,
5699 entries: HashMap::new(),
5700 };
5701 auth.set(
5702 "openrouter",
5703 AuthCredential::ApiKey {
5704 key: "stored-openrouter-key".to_string(),
5705 },
5706 );
5707
5708 let resolved = auth.resolve_api_key_with_env_lookup("openrouter", None, |var| match var {
5709 "OPENROUTER_API_KEY" => Some("env-openrouter-key".to_string()),
5710 _ => None,
5711 });
5712 assert_eq!(resolved.as_deref(), Some("env-openrouter-key"));
5713 }
5714
5715 #[test]
5716 fn test_resolve_api_key_empty_env_falls_through_to_stored() {
5717 let dir = tempfile::tempdir().expect("tmpdir");
5718 let auth_path = dir.path().join("auth.json");
5719 let mut auth = AuthStorage {
5720 path: auth_path,
5721 entries: HashMap::new(),
5722 };
5723 auth.set(
5724 "openai",
5725 AuthCredential::ApiKey {
5726 key: "stored-key".to_string(),
5727 },
5728 );
5729
5730 let resolved =
5732 auth.resolve_api_key_with_env_lookup("openai", None, |_| Some(String::new()));
5733 assert_eq!(
5734 resolved.as_deref(),
5735 Some("stored-key"),
5736 "empty env should fall through to stored"
5737 );
5738 }
5739
5740 #[test]
5741 fn test_resolve_api_key_whitespace_env_falls_through_to_stored() {
5742 let dir = tempfile::tempdir().expect("tmpdir");
5743 let auth_path = dir.path().join("auth.json");
5744 let mut auth = AuthStorage {
5745 path: auth_path,
5746 entries: HashMap::new(),
5747 };
5748 auth.set(
5749 "openai",
5750 AuthCredential::ApiKey {
5751 key: "stored-key".to_string(),
5752 },
5753 );
5754
5755 let resolved = auth.resolve_api_key_with_env_lookup("openai", None, |_| Some(" ".into()));
5756 assert_eq!(resolved.as_deref(), Some("stored-key"));
5757 }
5758
5759 #[test]
5760 fn test_resolve_api_key_anthropic_oauth_marks_for_bearer_lane() {
5761 let dir = tempfile::tempdir().expect("tmpdir");
5762 let auth_path = dir.path().join("auth.json");
5763 let mut auth = AuthStorage {
5764 path: auth_path,
5765 entries: HashMap::new(),
5766 };
5767 auth.set(
5768 "anthropic",
5769 AuthCredential::OAuth {
5770 access_token: "sk-ant-api-like-token".to_string(),
5771 refresh_token: "refresh-token".to_string(),
5772 expires: chrono::Utc::now().timestamp_millis() + 60_000,
5773 token_url: None,
5774 client_id: None,
5775 },
5776 );
5777
5778 let resolved = auth.resolve_api_key_with_env_lookup("anthropic", None, |_| None);
5779 let token = resolved.expect("resolved anthropic oauth token");
5780 assert_eq!(
5781 unmark_anthropic_oauth_bearer_token(&token),
5782 Some("sk-ant-api-like-token")
5783 );
5784 }
5785
5786 #[test]
5787 fn test_resolve_api_key_non_anthropic_oauth_is_not_marked() {
5788 let dir = tempfile::tempdir().expect("tmpdir");
5789 let auth_path = dir.path().join("auth.json");
5790 let mut auth = AuthStorage {
5791 path: auth_path,
5792 entries: HashMap::new(),
5793 };
5794 auth.set(
5795 "openai-codex",
5796 AuthCredential::OAuth {
5797 access_token: "codex-oauth-token".to_string(),
5798 refresh_token: "refresh-token".to_string(),
5799 expires: chrono::Utc::now().timestamp_millis() + 60_000,
5800 token_url: None,
5801 client_id: None,
5802 },
5803 );
5804
5805 let resolved = auth.resolve_api_key_with_env_lookup("openai-codex", None, |_| None);
5806 assert_eq!(resolved.as_deref(), Some("codex-oauth-token"));
5807 }
5808
5809 #[test]
5810 fn test_resolve_api_key_google_uses_gemini_env_fallback() {
5811 let dir = tempfile::tempdir().expect("tmpdir");
5812 let auth_path = dir.path().join("auth.json");
5813 let mut auth = AuthStorage {
5814 path: auth_path,
5815 entries: HashMap::new(),
5816 };
5817 auth.set(
5818 "google",
5819 AuthCredential::ApiKey {
5820 key: "stored-google-key".to_string(),
5821 },
5822 );
5823
5824 let resolved = auth.resolve_api_key_with_env_lookup("google", None, |var| match var {
5825 "GOOGLE_API_KEY" => Some(String::new()),
5826 "GEMINI_API_KEY" => Some("gemini-fallback-key".to_string()),
5827 _ => None,
5828 });
5829
5830 assert_eq!(resolved.as_deref(), Some("gemini-fallback-key"));
5831 }
5832
5833 #[test]
5834 fn test_resolve_api_key_gemini_alias_reads_google_stored_key() {
5835 let dir = tempfile::tempdir().expect("tmpdir");
5836 let auth_path = dir.path().join("auth.json");
5837 let mut auth = AuthStorage {
5838 path: auth_path,
5839 entries: HashMap::new(),
5840 };
5841 auth.set(
5842 "google",
5843 AuthCredential::ApiKey {
5844 key: "stored-google-key".to_string(),
5845 },
5846 );
5847
5848 let resolved = auth.resolve_api_key_with_env_lookup("gemini", None, |_| None);
5849 assert_eq!(resolved.as_deref(), Some("stored-google-key"));
5850 }
5851
5852 #[test]
5853 fn test_resolve_api_key_google_reads_legacy_gemini_alias_stored_key() {
5854 let dir = tempfile::tempdir().expect("tmpdir");
5855 let auth_path = dir.path().join("auth.json");
5856 let mut auth = AuthStorage {
5857 path: auth_path,
5858 entries: HashMap::new(),
5859 };
5860 auth.set(
5861 "gemini",
5862 AuthCredential::ApiKey {
5863 key: "legacy-gemini-key".to_string(),
5864 },
5865 );
5866
5867 let resolved = auth.resolve_api_key_with_env_lookup("google", None, |_| None);
5868 assert_eq!(resolved.as_deref(), Some("legacy-gemini-key"));
5869 }
5870
5871 #[test]
5872 fn test_resolve_api_key_qwen_uses_qwen_env_fallback() {
5873 let dir = tempfile::tempdir().expect("tmpdir");
5874 let auth_path = dir.path().join("auth.json");
5875 let mut auth = AuthStorage {
5876 path: auth_path,
5877 entries: HashMap::new(),
5878 };
5879 auth.set(
5880 "alibaba",
5881 AuthCredential::ApiKey {
5882 key: "stored-dashscope-key".to_string(),
5883 },
5884 );
5885
5886 let resolved = auth.resolve_api_key_with_env_lookup("qwen", None, |var| match var {
5887 "DASHSCOPE_API_KEY" => Some(String::new()),
5888 "QWEN_API_KEY" => Some("qwen-fallback-key".to_string()),
5889 _ => None,
5890 });
5891
5892 assert_eq!(resolved.as_deref(), Some("qwen-fallback-key"));
5893 }
5894
5895 #[test]
5896 fn test_resolve_api_key_kimi_uses_kimi_env_fallback() {
5897 let dir = tempfile::tempdir().expect("tmpdir");
5898 let auth_path = dir.path().join("auth.json");
5899 let mut auth = AuthStorage {
5900 path: auth_path,
5901 entries: HashMap::new(),
5902 };
5903 auth.set(
5904 "moonshotai",
5905 AuthCredential::ApiKey {
5906 key: "stored-moonshot-key".to_string(),
5907 },
5908 );
5909
5910 let resolved = auth.resolve_api_key_with_env_lookup("kimi", None, |var| match var {
5911 "MOONSHOT_API_KEY" => Some(String::new()),
5912 "KIMI_API_KEY" => Some("kimi-fallback-key".to_string()),
5913 _ => None,
5914 });
5915
5916 assert_eq!(resolved.as_deref(), Some("kimi-fallback-key"));
5917 }
5918
5919 #[test]
5920 fn test_resolve_api_key_primary_env_wins_over_alias_fallback() {
5921 let dir = tempfile::tempdir().expect("tmpdir");
5922 let auth_path = dir.path().join("auth.json");
5923 let auth = AuthStorage {
5924 path: auth_path,
5925 entries: HashMap::new(),
5926 };
5927
5928 let resolved = auth.resolve_api_key_with_env_lookup("alibaba", None, |var| match var {
5929 "DASHSCOPE_API_KEY" => Some("dashscope-primary".to_string()),
5930 "QWEN_API_KEY" => Some("qwen-secondary".to_string()),
5931 _ => None,
5932 });
5933
5934 assert_eq!(resolved.as_deref(), Some("dashscope-primary"));
5935 }
5936
5937 #[test]
5940 fn test_api_key_store_and_retrieve() {
5941 let dir = tempfile::tempdir().expect("tmpdir");
5942 let auth_path = dir.path().join("auth.json");
5943 let mut auth = AuthStorage {
5944 path: auth_path,
5945 entries: HashMap::new(),
5946 };
5947
5948 auth.set(
5949 "openai",
5950 AuthCredential::ApiKey {
5951 key: "sk-openai-test".to_string(),
5952 },
5953 );
5954
5955 assert_eq!(auth.api_key("openai").as_deref(), Some("sk-openai-test"));
5956 }
5957
5958 #[test]
5959 fn test_google_api_key_overwrite_persists_latest_value() {
5960 let dir = tempfile::tempdir().expect("tmpdir");
5961 let auth_path = dir.path().join("auth.json");
5962 let mut auth = AuthStorage {
5963 path: auth_path.clone(),
5964 entries: HashMap::new(),
5965 };
5966
5967 auth.set(
5968 "google",
5969 AuthCredential::ApiKey {
5970 key: "google-key-old".to_string(),
5971 },
5972 );
5973 auth.set(
5974 "google",
5975 AuthCredential::ApiKey {
5976 key: "google-key-new".to_string(),
5977 },
5978 );
5979 auth.save().expect("save");
5980
5981 let loaded = AuthStorage::load(auth_path).expect("load");
5982 assert_eq!(loaded.api_key("google").as_deref(), Some("google-key-new"));
5983 }
5984
5985 #[test]
5986 fn test_multiple_providers_stored_and_retrieved() {
5987 let dir = tempfile::tempdir().expect("tmpdir");
5988 let auth_path = dir.path().join("auth.json");
5989 let mut auth = AuthStorage {
5990 path: auth_path.clone(),
5991 entries: HashMap::new(),
5992 };
5993
5994 auth.set(
5995 "anthropic",
5996 AuthCredential::ApiKey {
5997 key: "sk-ant".to_string(),
5998 },
5999 );
6000 auth.set(
6001 "openai",
6002 AuthCredential::ApiKey {
6003 key: "sk-oai".to_string(),
6004 },
6005 );
6006 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
6007 auth.set(
6008 "google",
6009 AuthCredential::OAuth {
6010 access_token: "goog-token".to_string(),
6011 refresh_token: "goog-refresh".to_string(),
6012 expires: far_future,
6013 token_url: None,
6014 client_id: None,
6015 },
6016 );
6017 auth.save().expect("save");
6018
6019 let loaded = AuthStorage::load(auth_path).expect("load");
6021 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("sk-ant"));
6022 assert_eq!(loaded.api_key("openai").as_deref(), Some("sk-oai"));
6023 assert_eq!(loaded.api_key("google").as_deref(), Some("goog-token"));
6024 assert_eq!(loaded.entries.len(), 3);
6025 }
6026
6027 #[test]
6028 fn test_save_creates_parent_directories() {
6029 let dir = tempfile::tempdir().expect("tmpdir");
6030 let auth_path = dir.path().join("nested").join("dirs").join("auth.json");
6031
6032 let mut auth = AuthStorage {
6033 path: auth_path.clone(),
6034 entries: HashMap::new(),
6035 };
6036 auth.set(
6037 "anthropic",
6038 AuthCredential::ApiKey {
6039 key: "nested-key".to_string(),
6040 },
6041 );
6042 auth.save().expect("save should create parents");
6043 assert!(auth_path.exists());
6044
6045 let loaded = AuthStorage::load(auth_path).expect("load");
6046 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("nested-key"));
6047 }
6048
6049 #[cfg(unix)]
6050 #[test]
6051 fn test_save_sets_600_permissions() {
6052 use std::os::unix::fs::PermissionsExt;
6053
6054 let dir = tempfile::tempdir().expect("tmpdir");
6055 let auth_path = dir.path().join("auth.json");
6056
6057 let mut auth = AuthStorage {
6058 path: auth_path.clone(),
6059 entries: HashMap::new(),
6060 };
6061 auth.set(
6062 "anthropic",
6063 AuthCredential::ApiKey {
6064 key: "secret".to_string(),
6065 },
6066 );
6067 auth.save().expect("save");
6068
6069 let metadata = fs::metadata(&auth_path).expect("metadata");
6070 let mode = metadata.permissions().mode() & 0o777;
6071 assert_eq!(mode, 0o600, "auth.json should be owner-only read/write");
6072 }
6073
6074 #[test]
6075 fn test_save_uses_lockfile_and_preserves_original_on_persist_failure() {
6076 let dir = tempfile::tempdir().expect("tmpdir");
6077 let auth_path = dir.path().join("auth.json");
6078
6079 let mut original = AuthStorage {
6080 path: auth_path.clone(),
6081 entries: HashMap::new(),
6082 };
6083 original.set(
6084 "anthropic",
6085 AuthCredential::ApiKey {
6086 key: "old-key".to_string(),
6087 },
6088 );
6089 original.save().expect("save original auth");
6090
6091 let lock_path = auth_lock_path(&auth_path);
6092 assert!(
6093 lock_path.exists(),
6094 "expected sibling lockfile to be created"
6095 );
6096
6097 let mut replacement_entries = HashMap::new();
6098 replacement_entries.insert(
6099 "anthropic".to_string(),
6100 AuthCredential::ApiKey {
6101 key: "new-key".to_string(),
6102 },
6103 );
6104 let replacement_data = serde_json::to_string_pretty(&AuthFileRef {
6105 entries: &replacement_entries,
6106 })
6107 .expect("serialize replacement");
6108
6109 let mut temp_path = None;
6110 let err = AuthStorage::save_data_sync_with_hook(&auth_path, &replacement_data, |temp| {
6111 temp_path = Some(temp.path().to_path_buf());
6112 Err(std::io::Error::other("injected persist failure"))
6113 })
6114 .expect_err("persist hook should abort save");
6115 assert!(
6116 err.to_string().contains("injected persist failure"),
6117 "unexpected error: {err}"
6118 );
6119
6120 let temp_path = temp_path.expect("captured temp path");
6121 assert!(
6122 !temp_path.exists(),
6123 "temporary auth file should be cleaned up on failure"
6124 );
6125
6126 let reloaded = AuthStorage::load(auth_path).expect("reload auth");
6127 assert_eq!(reloaded.api_key("anthropic").as_deref(), Some("old-key"));
6128 }
6129
6130 #[test]
6133 fn test_api_key_returns_none_for_missing_provider() {
6134 let dir = tempfile::tempdir().expect("tmpdir");
6135 let auth_path = dir.path().join("auth.json");
6136 let auth = AuthStorage {
6137 path: auth_path,
6138 entries: HashMap::new(),
6139 };
6140 assert!(auth.api_key("nonexistent").is_none());
6141 }
6142
6143 #[test]
6144 fn test_get_returns_none_for_missing_provider() {
6145 let dir = tempfile::tempdir().expect("tmpdir");
6146 let auth_path = dir.path().join("auth.json");
6147 let auth = AuthStorage {
6148 path: auth_path,
6149 entries: HashMap::new(),
6150 };
6151 assert!(auth.get("nonexistent").is_none());
6152 }
6153
6154 #[test]
6157 fn test_env_keys_all_built_in_providers() {
6158 let providers = [
6159 ("anthropic", "ANTHROPIC_API_KEY"),
6160 ("openai", "OPENAI_API_KEY"),
6161 ("google", "GOOGLE_API_KEY"),
6162 ("google-vertex", "GOOGLE_CLOUD_API_KEY"),
6163 ("amazon-bedrock", "AWS_ACCESS_KEY_ID"),
6164 ("azure-openai", "AZURE_OPENAI_API_KEY"),
6165 ("github-copilot", "GITHUB_COPILOT_API_KEY"),
6166 ("xai", "XAI_API_KEY"),
6167 ("groq", "GROQ_API_KEY"),
6168 ("deepinfra", "DEEPINFRA_API_KEY"),
6169 ("cerebras", "CEREBRAS_API_KEY"),
6170 ("openrouter", "OPENROUTER_API_KEY"),
6171 ("mistral", "MISTRAL_API_KEY"),
6172 ("cohere", "COHERE_API_KEY"),
6173 ("perplexity", "PERPLEXITY_API_KEY"),
6174 ("deepseek", "DEEPSEEK_API_KEY"),
6175 ("fireworks", "FIREWORKS_API_KEY"),
6176 ];
6177 for (provider, expected_key) in providers {
6178 let keys = env_keys_for_provider(provider);
6179 assert!(!keys.is_empty(), "expected env key for {provider}");
6180 assert_eq!(
6181 keys[0], expected_key,
6182 "wrong primary env key for {provider}"
6183 );
6184 }
6185 }
6186
6187 #[test]
6188 fn test_env_keys_togetherai_has_two_variants() {
6189 let keys = env_keys_for_provider("togetherai");
6190 assert_eq!(keys.len(), 2);
6191 assert_eq!(keys[0], "TOGETHER_API_KEY");
6192 assert_eq!(keys[1], "TOGETHER_AI_API_KEY");
6193 }
6194
6195 #[test]
6196 fn test_env_keys_google_includes_gemini_fallback() {
6197 let keys = env_keys_for_provider("google");
6198 assert_eq!(keys, &["GOOGLE_API_KEY", "GEMINI_API_KEY"]);
6199 }
6200
6201 #[test]
6202 fn test_env_keys_moonshotai_aliases() {
6203 for alias in &["moonshotai", "moonshot", "kimi"] {
6204 let keys = env_keys_for_provider(alias);
6205 assert_eq!(
6206 keys,
6207 &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
6208 "alias {alias} should map to moonshot auth fallback key chain"
6209 );
6210 }
6211 }
6212
6213 #[test]
6214 fn test_env_keys_alibaba_aliases() {
6215 for alias in &["alibaba", "dashscope", "qwen"] {
6216 let keys = env_keys_for_provider(alias);
6217 assert_eq!(
6218 keys,
6219 &["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
6220 "alias {alias} should map to dashscope auth fallback key chain"
6221 );
6222 }
6223 }
6224
6225 #[test]
6226 fn test_env_keys_native_and_gateway_aliases() {
6227 let cases: [(&str, &[&str]); 7] = [
6228 ("gemini", &["GOOGLE_API_KEY", "GEMINI_API_KEY"]),
6229 ("fireworks-ai", &["FIREWORKS_API_KEY"]),
6230 (
6231 "bedrock",
6232 &[
6233 "AWS_ACCESS_KEY_ID",
6234 "AWS_SECRET_ACCESS_KEY",
6235 "AWS_SESSION_TOKEN",
6236 "AWS_BEARER_TOKEN_BEDROCK",
6237 "AWS_PROFILE",
6238 "AWS_REGION",
6239 ] as &[&str],
6240 ),
6241 ("azure", &["AZURE_OPENAI_API_KEY"]),
6242 ("vertexai", &["GOOGLE_CLOUD_API_KEY", "VERTEX_API_KEY"]),
6243 ("copilot", &["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"]),
6244 ("fireworks", &["FIREWORKS_API_KEY"]),
6245 ];
6246
6247 for (alias, expected) in cases {
6248 let keys = env_keys_for_provider(alias);
6249 assert_eq!(keys, expected, "alias {alias} should map to {expected:?}");
6250 }
6251 }
6252
6253 #[test]
6256 fn test_percent_encode_ascii_passthrough() {
6257 assert_eq!(percent_encode_component("hello"), "hello");
6258 assert_eq!(
6259 percent_encode_component("ABCDEFxyz0189-._~"),
6260 "ABCDEFxyz0189-._~"
6261 );
6262 }
6263
6264 #[test]
6265 fn test_percent_encode_spaces_and_special() {
6266 assert_eq!(percent_encode_component("hello world"), "hello%20world");
6267 assert_eq!(percent_encode_component("a&b=c"), "a%26b%3Dc");
6268 assert_eq!(percent_encode_component("100%"), "100%25");
6269 }
6270
6271 #[test]
6272 fn test_percent_decode_passthrough() {
6273 assert_eq!(percent_decode_component("hello").as_deref(), Some("hello"));
6274 }
6275
6276 #[test]
6277 fn test_percent_decode_encoded() {
6278 assert_eq!(
6279 percent_decode_component("hello%20world").as_deref(),
6280 Some("hello world")
6281 );
6282 assert_eq!(
6283 percent_decode_component("a%26b%3Dc").as_deref(),
6284 Some("a&b=c")
6285 );
6286 }
6287
6288 #[test]
6289 fn test_percent_decode_plus_as_space() {
6290 assert_eq!(
6291 percent_decode_component("hello+world").as_deref(),
6292 Some("hello world")
6293 );
6294 }
6295
6296 #[test]
6297 fn test_percent_decode_invalid_hex_returns_none() {
6298 assert!(percent_decode_component("hello%ZZ").is_none());
6299 assert!(percent_decode_component("trailing%2").is_none());
6300 assert!(percent_decode_component("trailing%").is_none());
6301 }
6302
6303 #[test]
6304 fn test_percent_encode_decode_roundtrip() {
6305 let inputs = ["hello world", "a=1&b=2", "special: 100% /path?q=v#frag"];
6306 for input in inputs {
6307 let encoded = percent_encode_component(input);
6308 let decoded = percent_decode_component(&encoded).expect("decode");
6309 assert_eq!(decoded, input, "roundtrip failed for: {input}");
6310 }
6311 }
6312
6313 #[test]
6316 fn test_parse_query_pairs_basic() {
6317 let pairs = parse_query_pairs("code=abc&state=def");
6318 assert_eq!(pairs.len(), 2);
6319 assert_eq!(pairs[0], ("code".to_string(), "abc".to_string()));
6320 assert_eq!(pairs[1], ("state".to_string(), "def".to_string()));
6321 }
6322
6323 #[test]
6324 fn test_parse_query_pairs_empty_value() {
6325 let pairs = parse_query_pairs("key=");
6326 assert_eq!(pairs.len(), 1);
6327 assert_eq!(pairs[0], ("key".to_string(), String::new()));
6328 }
6329
6330 #[test]
6331 fn test_parse_query_pairs_no_value() {
6332 let pairs = parse_query_pairs("key");
6333 assert_eq!(pairs.len(), 1);
6334 assert_eq!(pairs[0], ("key".to_string(), String::new()));
6335 }
6336
6337 #[test]
6338 fn test_parse_query_pairs_empty_string() {
6339 let pairs = parse_query_pairs("");
6340 assert!(pairs.is_empty());
6341 }
6342
6343 #[test]
6344 fn test_parse_query_pairs_encoded_values() {
6345 let pairs = parse_query_pairs("scope=read%20write&redirect=http%3A%2F%2Fexample.com");
6346 assert_eq!(pairs.len(), 2);
6347 assert_eq!(pairs[0].1, "read write");
6348 assert_eq!(pairs[1].1, "http://example.com");
6349 }
6350
6351 #[test]
6354 fn test_build_url_basic() {
6355 let url = build_url_with_query(
6356 "https://example.com/auth",
6357 &[("key", "val"), ("foo", "bar")],
6358 );
6359 assert_eq!(url, "https://example.com/auth?key=val&foo=bar");
6360 }
6361
6362 #[test]
6363 fn test_build_url_encodes_special_chars() {
6364 let url =
6365 build_url_with_query("https://example.com", &[("q", "hello world"), ("x", "a&b")]);
6366 assert!(url.contains("q=hello%20world"));
6367 assert!(url.contains("x=a%26b"));
6368 }
6369
6370 #[test]
6371 fn test_build_url_no_params() {
6372 let url = build_url_with_query("https://example.com", &[]);
6373 assert_eq!(url, "https://example.com?");
6374 }
6375
6376 #[test]
6379 fn test_parse_oauth_code_input_empty() {
6380 let (code, state) = parse_oauth_code_input("");
6381 assert!(code.is_none());
6382 assert!(state.is_none());
6383 }
6384
6385 #[test]
6386 fn test_parse_oauth_code_input_whitespace_only() {
6387 let (code, state) = parse_oauth_code_input(" ");
6388 assert!(code.is_none());
6389 assert!(state.is_none());
6390 }
6391
6392 #[test]
6393 fn test_parse_oauth_code_input_url_strips_fragment() {
6394 let (code, state) =
6395 parse_oauth_code_input("https://example.com/callback?code=abc&state=def#fragment");
6396 assert_eq!(code.as_deref(), Some("abc"));
6397 assert_eq!(state.as_deref(), Some("def"));
6398 }
6399
6400 #[test]
6401 fn test_parse_oauth_code_input_url_code_only() {
6402 let (code, state) = parse_oauth_code_input("https://example.com/callback?code=abc");
6403 assert_eq!(code.as_deref(), Some("abc"));
6404 assert!(state.is_none());
6405 }
6406
6407 #[test]
6408 fn test_parse_oauth_code_input_hash_empty_state() {
6409 let (code, state) = parse_oauth_code_input("abc#");
6410 assert_eq!(code.as_deref(), Some("abc"));
6411 assert!(state.is_none());
6412 }
6413
6414 #[test]
6415 fn test_parse_oauth_code_input_hash_empty_code() {
6416 let (code, state) = parse_oauth_code_input("#state-only");
6417 assert!(code.is_none());
6418 assert_eq!(state.as_deref(), Some("state-only"));
6419 }
6420
6421 #[test]
6424 fn test_oauth_expires_at_ms_subtracts_safety_margin() {
6425 let now_ms = chrono::Utc::now().timestamp_millis();
6426 let expires_in = 3600; let result = oauth_expires_at_ms(expires_in);
6428
6429 let expected_approx = now_ms + 3600 * 1000 - 5 * 60 * 1000;
6431 let diff = (result - expected_approx).unsigned_abs();
6432 assert!(diff < 1000, "expected ~{expected_approx}ms, got {result}ms");
6433 }
6434
6435 #[test]
6436 fn test_oauth_expires_at_ms_zero_expires_in() {
6437 let now_ms = chrono::Utc::now().timestamp_millis();
6438 let result = oauth_expires_at_ms(0);
6439
6440 let expected_approx = now_ms - 5 * 60 * 1000;
6442 let diff = (result - expected_approx).unsigned_abs();
6443 assert!(diff < 1000, "expected ~{expected_approx}ms, got {result}ms");
6444 }
6445
6446 #[test]
6447 fn test_oauth_expires_at_ms_saturates_for_huge_positive_expires_in() {
6448 let result = oauth_expires_at_ms(i64::MAX);
6449 assert_eq!(result, i64::MAX - 5 * 60 * 1000);
6450 }
6451
6452 #[test]
6453 fn test_oauth_expires_at_ms_handles_huge_negative_expires_in() {
6454 let result = oauth_expires_at_ms(i64::MIN);
6455 assert!(result <= chrono::Utc::now().timestamp_millis());
6456 }
6457
6458 #[test]
6461 fn test_set_overwrites_existing_credential() {
6462 let dir = tempfile::tempdir().expect("tmpdir");
6463 let auth_path = dir.path().join("auth.json");
6464 let mut auth = AuthStorage {
6465 path: auth_path,
6466 entries: HashMap::new(),
6467 };
6468
6469 auth.set(
6470 "anthropic",
6471 AuthCredential::ApiKey {
6472 key: "first-key".to_string(),
6473 },
6474 );
6475 assert_eq!(auth.api_key("anthropic").as_deref(), Some("first-key"));
6476
6477 auth.set(
6478 "anthropic",
6479 AuthCredential::ApiKey {
6480 key: "second-key".to_string(),
6481 },
6482 );
6483 assert_eq!(auth.api_key("anthropic").as_deref(), Some("second-key"));
6484 assert_eq!(auth.entries.len(), 1);
6485 }
6486
6487 #[test]
6488 fn test_save_then_overwrite_persists_latest() {
6489 let dir = tempfile::tempdir().expect("tmpdir");
6490 let auth_path = dir.path().join("auth.json");
6491
6492 {
6494 let mut auth = AuthStorage {
6495 path: auth_path.clone(),
6496 entries: HashMap::new(),
6497 };
6498 auth.set(
6499 "anthropic",
6500 AuthCredential::ApiKey {
6501 key: "old-key".to_string(),
6502 },
6503 );
6504 auth.save().expect("save");
6505 }
6506
6507 {
6509 let mut auth = AuthStorage::load(auth_path.clone()).expect("load");
6510 auth.set(
6511 "anthropic",
6512 AuthCredential::ApiKey {
6513 key: "new-key".to_string(),
6514 },
6515 );
6516 auth.save().expect("save");
6517 }
6518
6519 let loaded = AuthStorage::load(auth_path).expect("load");
6521 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("new-key"));
6522 }
6523
6524 #[test]
6527 fn test_load_default_auth_works_like_load() {
6528 let dir = tempfile::tempdir().expect("tmpdir");
6529 let auth_path = dir.path().join("auth.json");
6530
6531 let mut auth = AuthStorage {
6532 path: auth_path.clone(),
6533 entries: HashMap::new(),
6534 };
6535 auth.set(
6536 "anthropic",
6537 AuthCredential::ApiKey {
6538 key: "test-key".to_string(),
6539 },
6540 );
6541 auth.save().expect("save");
6542
6543 let loaded = load_default_auth(&auth_path).expect("load_default_auth");
6544 assert_eq!(loaded.api_key("anthropic").as_deref(), Some("test-key"));
6545 }
6546
6547 #[test]
6550 fn test_redact_known_secrets_replaces_secrets() {
6551 let text = r#"{"token":"secret123","other":"hello secret123 world"}"#;
6552 let redacted = redact_known_secrets(text, &["secret123"]);
6553 assert!(!redacted.contains("secret123"));
6554 assert!(redacted.contains("[REDACTED]"));
6555 }
6556
6557 #[test]
6558 fn test_redact_known_secrets_ignores_empty_secrets() {
6559 let text = "nothing to redact here";
6560 let redacted = redact_known_secrets(text, &["", " "]);
6561 assert_eq!(redacted, text);
6563 }
6564
6565 #[test]
6566 fn test_redact_known_secrets_multiple_secrets() {
6567 let text = "token =aaa refresh=bbb echo=aaa";
6568 let redacted = redact_known_secrets(text, &["aaa", "bbb"]);
6569 assert!(!redacted.contains("aaa"));
6570 assert!(!redacted.contains("bbb"));
6571 assert_eq!(
6572 redacted,
6573 "token =[REDACTED] refresh=[REDACTED] echo=[REDACTED]"
6574 );
6575 }
6576
6577 #[test]
6578 fn test_redact_known_secrets_no_match() {
6579 let text = "safe text with no secrets";
6580 let redacted = redact_known_secrets(text, &["not-present"]);
6581 assert_eq!(redacted, text);
6582 }
6583
6584 #[test]
6585 fn test_redact_known_secrets_redacts_oauth_json_fields_without_known_input() {
6586 let text = r#"{"access_token":"new-access","refresh_token":"new-refresh","nested":{"id_token":"new-id","safe":"ok"}}"#;
6587 let redacted = redact_known_secrets(text, &[]);
6588 assert!(redacted.contains("\"access_token\":\"[REDACTED]\""));
6589 assert!(redacted.contains("\"refresh_token\":\"[REDACTED]\""));
6590 assert!(redacted.contains("\"id_token\":\"[REDACTED]\""));
6591 assert!(redacted.contains("\"safe\":\"ok\""));
6592 assert!(!redacted.contains("new-access"));
6593 assert!(!redacted.contains("new-refresh"));
6594 assert!(!redacted.contains("new-id"));
6595 }
6596
6597 #[test]
6600 fn test_generate_pkce_unique_each_call() {
6601 let (v1, c1) = generate_pkce();
6602 let (v2, c2) = generate_pkce();
6603 assert_ne!(v1, v2, "verifiers should differ");
6604 assert_ne!(c1, c2, "challenges should differ");
6605 }
6606
6607 #[test]
6608 fn test_generate_pkce_challenge_is_sha256_of_verifier() {
6609 let (verifier, challenge) = generate_pkce();
6610 let expected_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
6611 .encode(sha2::Sha256::digest(verifier.as_bytes()));
6612 assert_eq!(challenge, expected_challenge);
6613 }
6614
6615 fn sample_copilot_config() -> CopilotOAuthConfig {
6618 CopilotOAuthConfig {
6619 client_id: "Iv1.test_copilot_id".to_string(),
6620 github_base_url: "https://github.com".to_string(),
6621 scopes: GITHUB_COPILOT_SCOPES.to_string(),
6622 }
6623 }
6624
6625 #[test]
6626 fn test_copilot_browser_oauth_requires_client_id() {
6627 let config = CopilotOAuthConfig {
6628 client_id: String::new(),
6629 ..CopilotOAuthConfig::default()
6630 };
6631 let err = start_copilot_browser_oauth(&config).unwrap_err();
6632 let msg = err.to_string();
6633 assert!(
6634 msg.contains("client_id"),
6635 "error should mention client_id: {msg}"
6636 );
6637 }
6638
6639 #[test]
6640 fn test_copilot_browser_oauth_url_contains_required_params() {
6641 let config = sample_copilot_config();
6642 let info = start_copilot_browser_oauth(&config).expect("start");
6643
6644 assert_eq!(info.provider, "github-copilot");
6645 assert!(!info.verifier.is_empty());
6646
6647 let (base, query) = info.url.split_once('?').expect("missing query");
6648 assert_eq!(base, GITHUB_OAUTH_AUTHORIZE_URL);
6649
6650 let params: std::collections::HashMap<_, _> =
6651 parse_query_pairs(query).into_iter().collect();
6652 assert_eq!(
6653 params.get("client_id").map(String::as_str),
6654 Some("Iv1.test_copilot_id")
6655 );
6656 assert_eq!(
6657 params.get("response_type").map(String::as_str),
6658 Some("code")
6659 );
6660 assert_eq!(
6661 params.get("scope").map(String::as_str),
6662 Some(GITHUB_COPILOT_SCOPES)
6663 );
6664 assert_eq!(
6665 params.get("code_challenge_method").map(String::as_str),
6666 Some("S256")
6667 );
6668 assert!(params.contains_key("code_challenge"));
6669 assert_eq!(
6670 params.get("state").map(String::as_str),
6671 Some(info.verifier.as_str())
6672 );
6673 }
6674
6675 #[test]
6676 fn test_copilot_browser_oauth_enterprise_url() {
6677 let config = CopilotOAuthConfig {
6678 client_id: "Iv1.enterprise".to_string(),
6679 github_base_url: "https://github.mycompany.com".to_string(),
6680 scopes: "read:user".to_string(),
6681 };
6682 let info = start_copilot_browser_oauth(&config).expect("start");
6683
6684 let (base, _) = info.url.split_once('?').expect("missing query");
6685 assert_eq!(base, "https://github.mycompany.com/login/oauth/authorize");
6686 }
6687
6688 #[test]
6689 fn test_copilot_browser_oauth_enterprise_trailing_slash() {
6690 let config = CopilotOAuthConfig {
6691 client_id: "Iv1.enterprise".to_string(),
6692 github_base_url: "https://github.mycompany.com/".to_string(),
6693 scopes: "read:user".to_string(),
6694 };
6695 let info = start_copilot_browser_oauth(&config).expect("start");
6696
6697 let (base, _) = info.url.split_once('?').expect("missing query");
6698 assert_eq!(base, "https://github.mycompany.com/login/oauth/authorize");
6699 }
6700
6701 #[test]
6702 fn test_copilot_browser_oauth_pkce_format() {
6703 let config = sample_copilot_config();
6704 let info = start_copilot_browser_oauth(&config).expect("start");
6705
6706 assert_eq!(info.verifier.len(), 43);
6707 assert!(!info.verifier.contains('+'));
6708 assert!(!info.verifier.contains('/'));
6709 assert!(!info.verifier.contains('='));
6710 }
6711
6712 #[test]
6713 #[cfg(unix)]
6714 fn test_copilot_browser_oauth_complete_success() {
6715 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6716 rt.expect("runtime").block_on(async {
6717 let token_url = spawn_json_server(
6718 200,
6719 r#"{"access_token":"ghu_test_access","refresh_token":"ghr_test_refresh","expires_in":28800}"#,
6720 );
6721
6722 let _config = CopilotOAuthConfig {
6724 client_id: "Iv1.test".to_string(),
6725 github_base_url: token_url.trim_end_matches("/token").replace("/token", ""),
6727 scopes: "read:user".to_string(),
6728 };
6729
6730 let cred = parse_github_token_response(
6734 r#"{"access_token":"ghu_test_access","refresh_token":"ghr_test_refresh","expires_in":28800}"#,
6735 )
6736 .expect("parse");
6737
6738 match cred {
6739 AuthCredential::OAuth {
6740 access_token,
6741 refresh_token,
6742 expires,
6743 ..
6744 } => {
6745 assert_eq!(access_token, "ghu_test_access");
6746 assert_eq!(refresh_token, "ghr_test_refresh");
6747 assert!(expires > chrono::Utc::now().timestamp_millis());
6748 }
6749 other => panic!(),
6750 }
6751 });
6752 }
6753
6754 #[test]
6755 fn test_parse_github_token_no_refresh_token() {
6756 let cred =
6757 parse_github_token_response(r#"{"access_token":"ghu_test","token_type":"bearer"}"#)
6758 .expect("parse");
6759
6760 match cred {
6761 AuthCredential::OAuth {
6762 access_token,
6763 refresh_token,
6764 ..
6765 } => {
6766 assert_eq!(access_token, "ghu_test");
6767 assert!(refresh_token.is_empty(), "should default to empty");
6768 }
6769 other => panic!(),
6770 }
6771 }
6772
6773 #[test]
6774 fn test_parse_github_token_no_expiry_uses_far_future() {
6775 let cred = parse_github_token_response(
6776 r#"{"access_token":"ghu_test","refresh_token":"ghr_test"}"#,
6777 )
6778 .expect("parse");
6779
6780 match cred {
6781 AuthCredential::OAuth { expires, .. } => {
6782 let now = chrono::Utc::now().timestamp_millis();
6783 let one_year_ms = 365 * 24 * 3600 * 1000_i64;
6784 assert!(
6786 expires > now + one_year_ms - 10 * 60 * 1000,
6787 "expected far-future expiry"
6788 );
6789 }
6790 other => panic!(),
6791 }
6792 }
6793
6794 #[test]
6795 fn test_parse_github_token_missing_access_token_fails() {
6796 let err = parse_github_token_response(r#"{"refresh_token":"ghr_test"}"#).unwrap_err();
6797 assert!(err.to_string().contains("access_token"));
6798 }
6799
6800 #[test]
6801 fn test_copilot_diagnostic_includes_troubleshooting() {
6802 let msg = copilot_diagnostic("Token exchange failed", "bad request");
6803 assert!(msg.contains("Token exchange failed"));
6804 assert!(msg.contains("Troubleshooting"));
6805 assert!(msg.contains("client_id"));
6806 assert!(msg.contains("Copilot subscription"));
6807 assert!(msg.contains("Enterprise"));
6808 }
6809
6810 #[test]
6813 fn test_device_code_response_deserialize() {
6814 let json = r#"{
6815 "device_code": "dc_test",
6816 "user_code": "ABCD-1234",
6817 "verification_uri": "https://github.com/login/device",
6818 "expires_in": 900,
6819 "interval": 5
6820 }"#;
6821 let resp: DeviceCodeResponse = serde_json::from_str(json).expect("parse");
6822 assert_eq!(resp.device_code, "dc_test");
6823 assert_eq!(resp.user_code, "ABCD-1234");
6824 assert_eq!(resp.verification_uri, "https://github.com/login/device");
6825 assert_eq!(resp.expires_in, 900);
6826 assert_eq!(resp.interval, 5);
6827 assert!(resp.verification_uri_complete.is_none());
6828 }
6829
6830 #[test]
6831 fn test_device_code_response_default_interval() {
6832 let json = r#"{
6833 "device_code": "dc",
6834 "user_code": "CODE",
6835 "verification_uri": "https://github.com/login/device",
6836 "expires_in": 600
6837 }"#;
6838 let resp: DeviceCodeResponse = serde_json::from_str(json).expect("parse");
6839 assert_eq!(resp.interval, 5, "default interval should be 5 seconds");
6840 }
6841
6842 #[test]
6843 fn test_device_code_response_with_complete_uri() {
6844 let json = r#"{
6845 "device_code": "dc",
6846 "user_code": "CODE",
6847 "verification_uri": "https://github.com/login/device",
6848 "verification_uri_complete": "https://github.com/login/device?user_code=CODE",
6849 "expires_in": 600,
6850 "interval": 10
6851 }"#;
6852 let resp: DeviceCodeResponse = serde_json::from_str(json).expect("parse");
6853 assert_eq!(
6854 resp.verification_uri_complete.as_deref(),
6855 Some("https://github.com/login/device?user_code=CODE")
6856 );
6857 }
6858
6859 #[test]
6860 fn test_copilot_device_flow_requires_client_id() {
6861 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6862 rt.expect("runtime").block_on(async {
6863 let config = CopilotOAuthConfig {
6864 client_id: String::new(),
6865 ..CopilotOAuthConfig::default()
6866 };
6867 let err = start_copilot_device_flow(&config).await.unwrap_err();
6868 assert!(err.to_string().contains("client_id"));
6869 });
6870 }
6871
6872 #[test]
6873 fn test_kimi_oauth_host_env_lookup_prefers_primary_host() {
6874 let host = kimi_code_oauth_host_with_env_lookup(|key| match key {
6875 "KIMI_CODE_OAUTH_HOST" => Some("https://primary.kimi.test".to_string()),
6876 "KIMI_OAUTH_HOST" => Some("https://fallback.kimi.test".to_string()),
6877 _ => None,
6878 });
6879 assert_eq!(host, "https://primary.kimi.test");
6880 }
6881
6882 #[test]
6883 fn test_kimi_share_dir_env_lookup_prefers_kimi_share_dir() {
6884 let share_dir = kimi_share_dir_with_env_lookup(|key| match key {
6885 "KIMI_SHARE_DIR" => Some("/tmp/custom-kimi-share".to_string()),
6886 "HOME" => Some("/tmp/home".to_string()),
6887 _ => None,
6888 });
6889 assert_eq!(
6890 share_dir,
6891 Some(PathBuf::from("/tmp/custom-kimi-share")),
6892 "KIMI_SHARE_DIR should override HOME-based default"
6893 );
6894 }
6895
6896 #[test]
6897 fn test_kimi_share_dir_env_lookup_falls_back_to_home() {
6898 let share_dir = kimi_share_dir_with_env_lookup(|key| match key {
6899 "KIMI_SHARE_DIR" => Some(" ".to_string()),
6900 "HOME" => Some("/tmp/home".to_string()),
6901 _ => None,
6902 });
6903 assert_eq!(share_dir, Some(PathBuf::from("/tmp/home/.kimi")));
6904 }
6905
6906 #[test]
6907 fn test_home_dir_env_lookup_falls_back_to_userprofile() {
6908 let home = home_dir_with_env_lookup(|key| match key {
6909 "HOME" => Some(" ".to_string()),
6910 "USERPROFILE" => Some("C:\\Users\\tester".to_string()),
6911 _ => None,
6912 });
6913 assert_eq!(home, Some(PathBuf::from("C:\\Users\\tester")));
6914 }
6915
6916 #[test]
6917 fn test_home_dir_env_lookup_falls_back_to_homedrive_homepath() {
6918 let home = home_dir_with_env_lookup(|key| match key {
6919 "HOMEDRIVE" => Some("C:".to_string()),
6920 "HOMEPATH" => Some("\\Users\\tester".to_string()),
6921 _ => None,
6922 });
6923 assert_eq!(home, Some(PathBuf::from("C:\\Users\\tester")));
6924 }
6925
6926 #[test]
6927 fn test_home_dir_env_lookup_homedrive_homepath_without_root_separator() {
6928 let home = home_dir_with_env_lookup(|key| match key {
6929 "HOMEDRIVE" => Some("C:".to_string()),
6930 "HOMEPATH" => Some("Users\\tester".to_string()),
6931 _ => None,
6932 });
6933 assert_eq!(home, Some(PathBuf::from("C:/Users\\tester")));
6934 }
6935
6936 #[test]
6937 fn test_read_external_kimi_code_access_token_from_share_dir_reads_unexpired_token() {
6938 let dir = tempfile::tempdir().expect("tmpdir");
6939 let share_dir = dir.path();
6940 let credentials_dir = share_dir.join("credentials");
6941 std::fs::create_dir_all(&credentials_dir).expect("create credentials dir");
6942 let path = credentials_dir.join("kimi-code.json");
6943 let expires_at = chrono::Utc::now().timestamp() + 3600;
6944 std::fs::write(
6945 &path,
6946 format!(r#"{{"access_token":" kimi-token ","expires_at":{expires_at}}}"#),
6947 )
6948 .expect("write token file");
6949
6950 let token = read_external_kimi_code_access_token_from_share_dir(share_dir);
6951 assert_eq!(token.as_deref(), Some("kimi-token"));
6952 }
6953
6954 #[test]
6955 fn test_read_external_kimi_code_access_token_from_share_dir_ignores_expired_token() {
6956 let dir = tempfile::tempdir().expect("tmpdir");
6957 let share_dir = dir.path();
6958 let credentials_dir = share_dir.join("credentials");
6959 std::fs::create_dir_all(&credentials_dir).expect("create credentials dir");
6960 let path = credentials_dir.join("kimi-code.json");
6961 let expires_at = chrono::Utc::now().timestamp() - 5;
6962 std::fs::write(
6963 &path,
6964 format!(r#"{{"access_token":"kimi-token","expires_at":{expires_at}}}"#),
6965 )
6966 .expect("write token file");
6967
6968 let token = read_external_kimi_code_access_token_from_share_dir(share_dir);
6969 assert!(token.is_none(), "expired Kimi token should be ignored");
6970 }
6971
6972 #[test]
6973 fn test_start_kimi_code_device_flow_parses_response() {
6974 let host = spawn_oauth_host_server(
6975 200,
6976 r#"{
6977 "device_code": "dc_test",
6978 "user_code": "ABCD-1234",
6979 "verification_uri": "https://auth.kimi.com/device",
6980 "verification_uri_complete": "https://auth.kimi.com/device?user_code=ABCD-1234",
6981 "expires_in": 900,
6982 "interval": 5
6983 }"#,
6984 );
6985 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
6986 rt.expect("runtime").block_on(async {
6987 let client = crate::http::client::Client::new();
6988 let response = start_kimi_code_device_flow_with_client(&client, &host)
6989 .await
6990 .expect("start kimi device flow");
6991 assert_eq!(response.device_code, "dc_test");
6992 assert_eq!(response.user_code, "ABCD-1234");
6993 assert_eq!(response.expires_in, 900);
6994 assert_eq!(response.interval, 5);
6995 assert_eq!(
6996 response.verification_uri_complete.as_deref(),
6997 Some("https://auth.kimi.com/device?user_code=ABCD-1234")
6998 );
6999 });
7000 }
7001
7002 #[test]
7003 fn test_poll_kimi_code_device_flow_success_returns_oauth_credential() {
7004 let host = spawn_oauth_host_server(
7005 200,
7006 r#"{"access_token":"kimi-at","refresh_token":"kimi-rt","expires_in":3600}"#,
7007 );
7008 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
7009 rt.expect("runtime").block_on(async {
7010 let client = crate::http::client::Client::new();
7011 let result =
7012 poll_kimi_code_device_flow_with_client(&client, &host, "device-code").await;
7013 match result {
7014 DeviceFlowPollResult::Success(AuthCredential::OAuth {
7015 access_token,
7016 refresh_token,
7017 token_url,
7018 client_id,
7019 ..
7020 }) => {
7021 let expected_token_url = format!("{host}{KIMI_CODE_TOKEN_PATH}");
7022 assert_eq!(access_token, "kimi-at");
7023 assert_eq!(refresh_token, "kimi-rt");
7024 assert_eq!(token_url.as_deref(), Some(expected_token_url.as_str()));
7025 assert_eq!(client_id.as_deref(), Some(KIMI_CODE_OAUTH_CLIENT_ID));
7026 }
7027 other => panic!(),
7028 }
7029 });
7030 }
7031
7032 #[test]
7033 fn test_poll_kimi_code_device_flow_pending_state() {
7034 let host = spawn_oauth_host_server(
7035 400,
7036 r#"{"error":"authorization_pending","error_description":"wait"}"#,
7037 );
7038 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
7039 rt.expect("runtime").block_on(async {
7040 let client = crate::http::client::Client::new();
7041 let result =
7042 poll_kimi_code_device_flow_with_client(&client, &host, "device-code").await;
7043 assert!(matches!(result, DeviceFlowPollResult::Pending));
7044 });
7045 }
7046
7047 fn sample_gitlab_config() -> GitLabOAuthConfig {
7050 GitLabOAuthConfig {
7051 client_id: "gl_test_app_id".to_string(),
7052 base_url: GITLAB_DEFAULT_BASE_URL.to_string(),
7053 scopes: GITLAB_DEFAULT_SCOPES.to_string(),
7054 redirect_uri: Some("http://localhost:8765/callback".to_string()),
7055 }
7056 }
7057
7058 #[test]
7059 fn test_gitlab_oauth_requires_client_id() {
7060 let config = GitLabOAuthConfig {
7061 client_id: String::new(),
7062 ..GitLabOAuthConfig::default()
7063 };
7064 let err = start_gitlab_oauth(&config).unwrap_err();
7065 let msg = err.to_string();
7066 assert!(
7067 msg.contains("client_id"),
7068 "error should mention client_id: {msg}"
7069 );
7070 assert!(msg.contains("Settings"), "should mention GitLab settings");
7071 }
7072
7073 #[test]
7074 fn test_gitlab_oauth_url_contains_required_params() {
7075 let config = sample_gitlab_config();
7076 let info = start_gitlab_oauth(&config).expect("start");
7077
7078 assert_eq!(info.provider, "gitlab");
7079 assert!(!info.verifier.is_empty());
7080
7081 let (base, query) = info.url.split_once('?').expect("missing query");
7082 assert_eq!(base, "https://gitlab.com/oauth/authorize");
7083
7084 let params: std::collections::HashMap<_, _> =
7085 parse_query_pairs(query).into_iter().collect();
7086 assert_eq!(
7087 params.get("client_id").map(String::as_str),
7088 Some("gl_test_app_id")
7089 );
7090 assert_eq!(
7091 params.get("response_type").map(String::as_str),
7092 Some("code")
7093 );
7094 assert_eq!(
7095 params.get("scope").map(String::as_str),
7096 Some(GITLAB_DEFAULT_SCOPES)
7097 );
7098 assert_eq!(
7099 params.get("redirect_uri").map(String::as_str),
7100 Some("http://localhost:8765/callback")
7101 );
7102 assert_eq!(
7103 params.get("code_challenge_method").map(String::as_str),
7104 Some("S256")
7105 );
7106 assert!(params.contains_key("code_challenge"));
7107 assert_eq!(
7108 params.get("state").map(String::as_str),
7109 Some(info.verifier.as_str())
7110 );
7111 }
7112
7113 #[test]
7114 fn test_gitlab_oauth_self_hosted_url() {
7115 let config = GitLabOAuthConfig {
7116 client_id: "gl_self_hosted".to_string(),
7117 base_url: "https://gitlab.mycompany.com".to_string(),
7118 scopes: "api".to_string(),
7119 redirect_uri: None,
7120 };
7121 let info = start_gitlab_oauth(&config).expect("start");
7122
7123 let (base, _) = info.url.split_once('?').expect("missing query");
7124 assert_eq!(base, "https://gitlab.mycompany.com/oauth/authorize");
7125 assert!(
7126 info.instructions
7127 .as_deref()
7128 .unwrap_or("")
7129 .contains("gitlab.mycompany.com"),
7130 "instructions should mention the base URL"
7131 );
7132 }
7133
7134 #[test]
7135 fn test_gitlab_oauth_self_hosted_trailing_slash() {
7136 let config = GitLabOAuthConfig {
7137 client_id: "gl_self_hosted".to_string(),
7138 base_url: "https://gitlab.mycompany.com/".to_string(),
7139 scopes: "api".to_string(),
7140 redirect_uri: None,
7141 };
7142 let info = start_gitlab_oauth(&config).expect("start");
7143
7144 let (base, _) = info.url.split_once('?').expect("missing query");
7145 assert_eq!(base, "https://gitlab.mycompany.com/oauth/authorize");
7146 }
7147
7148 #[test]
7149 fn test_gitlab_oauth_no_redirect_uri() {
7150 let config = GitLabOAuthConfig {
7153 client_id: "gl_no_redirect".to_string(),
7154 base_url: GITLAB_DEFAULT_BASE_URL.to_string(),
7155 scopes: "api".to_string(),
7156 redirect_uri: None,
7157 };
7158 let info = start_gitlab_oauth(&config).expect("start");
7159
7160 let (_, query) = info.url.split_once('?').expect("missing query");
7161 let params: std::collections::HashMap<_, _> =
7162 parse_query_pairs(query).into_iter().collect();
7163
7164 assert!(
7167 info.redirect_uri
7168 .as_deref()
7169 .is_some_and(|uri| uri.starts_with("http://localhost:")),
7170 "auto-generated redirect_uri should be a localhost URL, got {:?}",
7171 info.redirect_uri
7172 );
7173 assert!(
7174 params.contains_key("redirect_uri"),
7175 "redirect_uri should be included in the authorize URL"
7176 );
7177 assert!(
7178 info.callback_server.is_some(),
7179 "callback_server should be pre-bound"
7180 );
7181 }
7182
7183 #[test]
7184 fn test_gitlab_oauth_pkce_format() {
7185 let config = sample_gitlab_config();
7186 let info = start_gitlab_oauth(&config).expect("start");
7187
7188 assert_eq!(info.verifier.len(), 43);
7189 assert!(!info.verifier.contains('+'));
7190 assert!(!info.verifier.contains('/'));
7191 assert!(!info.verifier.contains('='));
7192 }
7193
7194 #[test]
7195 #[cfg(unix)]
7196 fn test_gitlab_oauth_complete_success() {
7197 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
7198 rt.expect("runtime").block_on(async {
7199 let token_url = spawn_json_server(
7200 200,
7201 r#"{"access_token":"glpat-test_access","refresh_token":"glrt-test_refresh","expires_in":7200,"token_type":"bearer"}"#,
7202 );
7203
7204 let response: OAuthTokenResponse = serde_json::from_str(
7206 r#"{"access_token":"glpat-test_access","refresh_token":"glrt-test_refresh","expires_in":7200}"#,
7207 )
7208 .expect("parse");
7209
7210 let cred = AuthCredential::OAuth {
7211 access_token: response.access_token,
7212 refresh_token: response.refresh_token,
7213 expires: oauth_expires_at_ms(response.expires_in),
7214 token_url: None,
7215 client_id: None,
7216 };
7217
7218 match cred {
7219 AuthCredential::OAuth {
7220 access_token,
7221 refresh_token,
7222 expires,
7223 ..
7224 } => {
7225 assert_eq!(access_token, "glpat-test_access");
7226 assert_eq!(refresh_token, "glrt-test_refresh");
7227 assert!(expires > chrono::Utc::now().timestamp_millis());
7228 }
7229 other => panic!(),
7230 }
7231
7232 let _ = token_url;
7234 });
7235 }
7236
7237 #[test]
7238 fn test_gitlab_diagnostic_includes_troubleshooting() {
7239 let msg = gitlab_diagnostic("https://gitlab.com", "Token exchange failed", "bad request");
7240 assert!(msg.contains("Token exchange failed"));
7241 assert!(msg.contains("Troubleshooting"));
7242 assert!(msg.contains("client_id"));
7243 assert!(msg.contains("Settings > Applications"));
7244 assert!(msg.contains("https://gitlab.com"));
7245 }
7246
7247 #[test]
7248 fn test_gitlab_diagnostic_self_hosted_url_in_message() {
7249 let msg = gitlab_diagnostic("https://gitlab.mycompany.com", "Auth failed", "HTTP 401");
7250 assert!(
7251 msg.contains("gitlab.mycompany.com"),
7252 "should reference the self-hosted URL"
7253 );
7254 }
7255
7256 #[test]
7259 fn test_env_keys_gitlab_provider() {
7260 let keys = env_keys_for_provider("gitlab");
7261 assert_eq!(keys, &["GITLAB_TOKEN", "GITLAB_API_KEY"]);
7262 }
7263
7264 #[test]
7265 fn test_env_keys_gitlab_duo_alias() {
7266 let keys = env_keys_for_provider("gitlab-duo");
7267 assert_eq!(keys, &["GITLAB_TOKEN", "GITLAB_API_KEY"]);
7268 }
7269
7270 #[test]
7271 fn test_env_keys_copilot_includes_github_token() {
7272 let keys = env_keys_for_provider("github-copilot");
7273 assert_eq!(keys, &["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"]);
7274 }
7275
7276 #[test]
7279 fn test_copilot_config_default() {
7280 let config = CopilotOAuthConfig::default();
7281 assert!(config.client_id.is_empty());
7282 assert_eq!(config.github_base_url, "https://github.com");
7283 assert_eq!(config.scopes, GITHUB_COPILOT_SCOPES);
7284 }
7285
7286 #[test]
7287 fn test_gitlab_config_default() {
7288 let config = GitLabOAuthConfig::default();
7289 assert!(config.client_id.is_empty());
7290 assert_eq!(config.base_url, GITLAB_DEFAULT_BASE_URL);
7291 assert_eq!(config.scopes, GITLAB_DEFAULT_SCOPES);
7292 assert!(config.redirect_uri.is_none());
7293 }
7294
7295 #[test]
7298 fn test_trim_trailing_slash_noop() {
7299 assert_eq!(
7300 trim_trailing_slash("https://github.com"),
7301 "https://github.com"
7302 );
7303 }
7304
7305 #[test]
7306 fn test_trim_trailing_slash_single() {
7307 assert_eq!(
7308 trim_trailing_slash("https://github.com/"),
7309 "https://github.com"
7310 );
7311 }
7312
7313 #[test]
7314 fn test_trim_trailing_slash_multiple() {
7315 assert_eq!(
7316 trim_trailing_slash("https://github.com///"),
7317 "https://github.com"
7318 );
7319 }
7320
7321 #[test]
7324 fn test_aws_credentials_round_trip() {
7325 let cred = AuthCredential::AwsCredentials {
7326 access_key_id: "AKIAEXAMPLE".to_string(),
7327 secret_access_key: "wJalrXUtnFEMI/SECRET".to_string(),
7328 session_token: Some("FwoGZX...session".to_string()),
7329 region: Some("us-west-2".to_string()),
7330 };
7331 let json = serde_json::to_string(&cred).expect("serialize");
7332 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
7333 match parsed {
7334 AuthCredential::AwsCredentials {
7335 access_key_id,
7336 secret_access_key,
7337 session_token,
7338 region,
7339 } => {
7340 assert_eq!(access_key_id, "AKIAEXAMPLE");
7341 assert_eq!(secret_access_key, "wJalrXUtnFEMI/SECRET");
7342 assert_eq!(session_token.as_deref(), Some("FwoGZX...session"));
7343 assert_eq!(region.as_deref(), Some("us-west-2"));
7344 }
7345 other => panic!(),
7346 }
7347 }
7348
7349 #[test]
7350 fn test_aws_credentials_without_optional_fields() {
7351 let json =
7352 r#"{"type":"aws_credentials","access_key_id":"AKIA","secret_access_key":"secret"}"#;
7353 let cred: AuthCredential = serde_json::from_str(json).expect("deserialize");
7354 match cred {
7355 AuthCredential::AwsCredentials {
7356 session_token,
7357 region,
7358 ..
7359 } => {
7360 assert!(session_token.is_none());
7361 assert!(region.is_none());
7362 }
7363 other => panic!(),
7364 }
7365 }
7366
7367 #[test]
7368 fn test_bearer_token_round_trip() {
7369 let cred = AuthCredential::BearerToken {
7370 token: "my-gateway-token-123".to_string(),
7371 };
7372 let json = serde_json::to_string(&cred).expect("serialize");
7373 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
7374 match parsed {
7375 AuthCredential::BearerToken { token } => {
7376 assert_eq!(token, "my-gateway-token-123");
7377 }
7378 other => panic!(),
7379 }
7380 }
7381
7382 #[test]
7383 fn test_service_key_round_trip() {
7384 let cred = AuthCredential::ServiceKey {
7385 client_id: Some("sap-client-id".to_string()),
7386 client_secret: Some("sap-secret".to_string()),
7387 token_url: Some("https://auth.sap.com/oauth/token".to_string()),
7388 service_url: Some("https://api.ai.sap.com".to_string()),
7389 };
7390 let json = serde_json::to_string(&cred).expect("serialize");
7391 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
7392 match parsed {
7393 AuthCredential::ServiceKey {
7394 client_id,
7395 client_secret,
7396 token_url,
7397 service_url,
7398 } => {
7399 assert_eq!(client_id.as_deref(), Some("sap-client-id"));
7400 assert_eq!(client_secret.as_deref(), Some("sap-secret"));
7401 assert_eq!(
7402 token_url.as_deref(),
7403 Some("https://auth.sap.com/oauth/token")
7404 );
7405 assert_eq!(service_url.as_deref(), Some("https://api.ai.sap.com"));
7406 }
7407 other => panic!(),
7408 }
7409 }
7410
7411 #[test]
7412 fn test_service_key_without_optional_fields() {
7413 let json = r#"{"type":"service_key"}"#;
7414 let cred: AuthCredential = serde_json::from_str(json).expect("deserialize");
7415 match cred {
7416 AuthCredential::ServiceKey {
7417 client_id,
7418 client_secret,
7419 token_url,
7420 service_url,
7421 } => {
7422 assert!(client_id.is_none());
7423 assert!(client_secret.is_none());
7424 assert!(token_url.is_none());
7425 assert!(service_url.is_none());
7426 }
7427 other => panic!(),
7428 }
7429 }
7430
7431 #[test]
7434 fn test_api_key_returns_bearer_token() {
7435 let dir = tempfile::tempdir().expect("tmpdir");
7436 let mut auth = AuthStorage {
7437 path: dir.path().join("auth.json"),
7438 entries: HashMap::new(),
7439 };
7440 auth.set(
7441 "my-gateway",
7442 AuthCredential::BearerToken {
7443 token: "gw-tok-123".to_string(),
7444 },
7445 );
7446 assert_eq!(auth.api_key("my-gateway").as_deref(), Some("gw-tok-123"));
7447 }
7448
7449 #[test]
7450 fn test_api_key_returns_aws_access_key_id() {
7451 let dir = tempfile::tempdir().expect("tmpdir");
7452 let mut auth = AuthStorage {
7453 path: dir.path().join("auth.json"),
7454 entries: HashMap::new(),
7455 };
7456 auth.set(
7457 "amazon-bedrock",
7458 AuthCredential::AwsCredentials {
7459 access_key_id: "AKIAEXAMPLE".to_string(),
7460 secret_access_key: "secret".to_string(),
7461 session_token: None,
7462 region: None,
7463 },
7464 );
7465 assert_eq!(
7466 auth.api_key("amazon-bedrock").as_deref(),
7467 Some("AKIAEXAMPLE")
7468 );
7469 }
7470
7471 #[test]
7472 fn test_api_key_returns_none_for_service_key() {
7473 let dir = tempfile::tempdir().expect("tmpdir");
7474 let mut auth = AuthStorage {
7475 path: dir.path().join("auth.json"),
7476 entries: HashMap::new(),
7477 };
7478 auth.set(
7479 "sap-ai-core",
7480 AuthCredential::ServiceKey {
7481 client_id: Some("id".to_string()),
7482 client_secret: Some("secret".to_string()),
7483 token_url: Some("https://auth.example.com".to_string()),
7484 service_url: Some("https://api.example.com".to_string()),
7485 },
7486 );
7487 assert!(auth.api_key("sap-ai-core").is_none());
7488 }
7489
7490 fn empty_auth() -> AuthStorage {
7493 let dir = tempfile::tempdir().expect("tmpdir");
7494 AuthStorage {
7495 path: dir.path().join("auth.json"),
7496 entries: HashMap::new(),
7497 }
7498 }
7499
7500 #[test]
7501 fn test_aws_bearer_token_env_wins() {
7502 let auth = empty_auth();
7503 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7504 "AWS_BEARER_TOKEN_BEDROCK" => Some("bearer-tok-env".to_string()),
7505 "AWS_REGION" => Some("eu-west-1".to_string()),
7506 "AWS_ACCESS_KEY_ID" => Some("AKIA_SHOULD_NOT_WIN".to_string()),
7507 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
7508 _ => None,
7509 });
7510 assert_eq!(
7511 result,
7512 Some(AwsResolvedCredentials::Bearer {
7513 token: "bearer-tok-env".to_string(),
7514 region: "eu-west-1".to_string(),
7515 })
7516 );
7517 }
7518
7519 #[test]
7520 fn test_aws_env_sigv4_credentials() {
7521 let auth = empty_auth();
7522 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7523 "AWS_ACCESS_KEY_ID" => Some("AKIATEST".to_string()),
7524 "AWS_SECRET_ACCESS_KEY" => Some("secretTEST".to_string()),
7525 "AWS_SESSION_TOKEN" => Some("session123".to_string()),
7526 "AWS_REGION" => Some("ap-southeast-1".to_string()),
7527 _ => None,
7528 });
7529 assert_eq!(
7530 result,
7531 Some(AwsResolvedCredentials::Sigv4 {
7532 access_key_id: "AKIATEST".to_string(),
7533 secret_access_key: "secretTEST".to_string(),
7534 session_token: Some("session123".to_string()),
7535 region: "ap-southeast-1".to_string(),
7536 })
7537 );
7538 }
7539
7540 #[test]
7541 fn test_aws_env_sigv4_without_session_token() {
7542 let auth = empty_auth();
7543 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7544 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
7545 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
7546 _ => None,
7547 });
7548 assert_eq!(
7549 result,
7550 Some(AwsResolvedCredentials::Sigv4 {
7551 access_key_id: "AKIA".to_string(),
7552 secret_access_key: "secret".to_string(),
7553 session_token: None,
7554 region: "us-east-1".to_string(),
7555 })
7556 );
7557 }
7558
7559 #[test]
7560 fn test_aws_profile_credentials_from_files() {
7561 let auth = empty_auth();
7562 let dir = tempfile::tempdir().expect("tmpdir");
7563 let cred_path = dir.path().join("credentials");
7564 let config_path = dir.path().join("config");
7565 std::fs::write(
7566 &cred_path,
7567 "[default]\naws_access_key_id = AKIA_DEFAULT\naws_secret_access_key = default_secret\n\n[dev]\naws_access_key_id = AKIA_DEV\naws_secret_access_key = dev_secret\naws_session_token = dev_session\n",
7568 )
7569 .expect("write credentials");
7570 std::fs::write(
7571 &config_path,
7572 "[default]\nregion = us-east-2\n\n[profile dev]\nregion = us-west-2\n",
7573 )
7574 .expect("write config");
7575
7576 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7577 "AWS_PROFILE" => Some("dev".to_string()),
7578 "AWS_SHARED_CREDENTIALS_FILE" => Some(cred_path.to_string_lossy().to_string()),
7579 "AWS_CONFIG_FILE" => Some(config_path.to_string_lossy().to_string()),
7580 _ => None,
7581 });
7582
7583 assert_eq!(
7584 result,
7585 Some(AwsResolvedCredentials::Sigv4 {
7586 access_key_id: "AKIA_DEV".to_string(),
7587 secret_access_key: "dev_secret".to_string(),
7588 session_token: Some("dev_session".to_string()),
7589 region: "us-west-2".to_string(),
7590 })
7591 );
7592 }
7593
7594 #[test]
7595 fn test_aws_profile_env_region_overrides_config() {
7596 let auth = empty_auth();
7597 let dir = tempfile::tempdir().expect("tmpdir");
7598 let cred_path = dir.path().join("credentials");
7599 let config_path = dir.path().join("config");
7600 std::fs::write(
7601 &cred_path,
7602 "[dev]\naws_access_key_id = AKIA_DEV\naws_secret_access_key = dev_secret\n",
7603 )
7604 .expect("write credentials");
7605 std::fs::write(&config_path, "[profile dev]\nregion = us-west-2\n").expect("write config");
7606
7607 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7608 "AWS_PROFILE" => Some("dev".to_string()),
7609 "AWS_SHARED_CREDENTIALS_FILE" => Some(cred_path.to_string_lossy().to_string()),
7610 "AWS_CONFIG_FILE" => Some(config_path.to_string_lossy().to_string()),
7611 "AWS_REGION" => Some("eu-north-1".to_string()),
7612 _ => None,
7613 });
7614
7615 assert_eq!(
7616 result,
7617 Some(AwsResolvedCredentials::Sigv4 {
7618 access_key_id: "AKIA_DEV".to_string(),
7619 secret_access_key: "dev_secret".to_string(),
7620 session_token: None,
7621 region: "eu-north-1".to_string(),
7622 })
7623 );
7624 }
7625
7626 #[test]
7627 fn test_aws_profile_missing_falls_back_to_auth() {
7628 let dir = tempfile::tempdir().expect("tmpdir");
7629 let mut auth = AuthStorage {
7630 path: dir.path().join("auth.json"),
7631 entries: HashMap::new(),
7632 };
7633 auth.set(
7634 "amazon-bedrock",
7635 AuthCredential::AwsCredentials {
7636 access_key_id: "AKIA_STORED".to_string(),
7637 secret_access_key: "secret_stored".to_string(),
7638 session_token: None,
7639 region: Some("us-west-1".to_string()),
7640 },
7641 );
7642 let cred_path = dir.path().join("credentials");
7643 std::fs::write(
7644 &cred_path,
7645 "[default]\naws_access_key_id = AKIA_DEFAULT\naws_secret_access_key = default_secret\n",
7646 )
7647 .expect("write credentials");
7648 let config_path = dir.path().join("config");
7649 std::fs::write(&config_path, "[default]\nregion = us-east-1\n").expect("write config");
7650
7651 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7652 "AWS_PROFILE" => Some("missing".to_string()),
7653 "AWS_SHARED_CREDENTIALS_FILE" => Some(cred_path.to_string_lossy().to_string()),
7654 "AWS_CONFIG_FILE" => Some(config_path.to_string_lossy().to_string()),
7655 _ => None,
7656 });
7657
7658 assert_eq!(
7659 result,
7660 Some(AwsResolvedCredentials::Sigv4 {
7661 access_key_id: "AKIA_STORED".to_string(),
7662 secret_access_key: "secret_stored".to_string(),
7663 session_token: None,
7664 region: "us-west-1".to_string(),
7665 })
7666 );
7667 }
7668
7669 #[test]
7670 fn test_aws_default_region_fallback() {
7671 let auth = empty_auth();
7672 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7673 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
7674 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
7675 "AWS_DEFAULT_REGION" => Some("ca-central-1".to_string()),
7676 _ => None,
7677 });
7678 match result {
7679 Some(AwsResolvedCredentials::Sigv4 { region, .. }) => {
7680 assert_eq!(region, "ca-central-1");
7681 }
7682 other => panic!(),
7683 }
7684 }
7685
7686 #[test]
7687 fn test_aws_stored_credentials_fallback() {
7688 let dir = tempfile::tempdir().expect("tmpdir");
7689 let mut auth = AuthStorage {
7690 path: dir.path().join("auth.json"),
7691 entries: HashMap::new(),
7692 };
7693 auth.set(
7694 "amazon-bedrock",
7695 AuthCredential::AwsCredentials {
7696 access_key_id: "AKIA_STORED".to_string(),
7697 secret_access_key: "secret_stored".to_string(),
7698 session_token: None,
7699 region: Some("us-west-2".to_string()),
7700 },
7701 );
7702 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
7703 assert_eq!(
7704 result,
7705 Some(AwsResolvedCredentials::Sigv4 {
7706 access_key_id: "AKIA_STORED".to_string(),
7707 secret_access_key: "secret_stored".to_string(),
7708 session_token: None,
7709 region: "us-west-2".to_string(),
7710 })
7711 );
7712 }
7713
7714 #[test]
7715 fn test_aws_stored_credentials_accept_alias_and_case_insensitive_entry() {
7716 let dir = tempfile::tempdir().expect("tmpdir");
7717 let mut auth = AuthStorage {
7718 path: dir.path().join("auth.json"),
7719 entries: HashMap::new(),
7720 };
7721 auth.set(
7722 "BedRock",
7723 AuthCredential::AwsCredentials {
7724 access_key_id: "AKIA_ALIAS".to_string(),
7725 secret_access_key: "alias-secret".to_string(),
7726 session_token: Some("alias-session".to_string()),
7727 region: Some("eu-central-1".to_string()),
7728 },
7729 );
7730 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
7731 assert_eq!(
7732 result,
7733 Some(AwsResolvedCredentials::Sigv4 {
7734 access_key_id: "AKIA_ALIAS".to_string(),
7735 secret_access_key: "alias-secret".to_string(),
7736 session_token: Some("alias-session".to_string()),
7737 region: "eu-central-1".to_string(),
7738 })
7739 );
7740 }
7741
7742 #[test]
7743 fn test_aws_stored_bearer_fallback() {
7744 let dir = tempfile::tempdir().expect("tmpdir");
7745 let mut auth = AuthStorage {
7746 path: dir.path().join("auth.json"),
7747 entries: HashMap::new(),
7748 };
7749 auth.set(
7750 "amazon-bedrock",
7751 AuthCredential::BearerToken {
7752 token: "stored-bearer".to_string(),
7753 },
7754 );
7755 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
7756 assert_eq!(
7757 result,
7758 Some(AwsResolvedCredentials::Bearer {
7759 token: "stored-bearer".to_string(),
7760 region: "us-east-1".to_string(),
7761 })
7762 );
7763 }
7764
7765 #[test]
7766 fn test_aws_env_beats_stored() {
7767 let dir = tempfile::tempdir().expect("tmpdir");
7768 let mut auth = AuthStorage {
7769 path: dir.path().join("auth.json"),
7770 entries: HashMap::new(),
7771 };
7772 auth.set(
7773 "amazon-bedrock",
7774 AuthCredential::AwsCredentials {
7775 access_key_id: "AKIA_STORED".to_string(),
7776 secret_access_key: "stored_secret".to_string(),
7777 session_token: None,
7778 region: None,
7779 },
7780 );
7781 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7782 "AWS_ACCESS_KEY_ID" => Some("AKIA_ENV".to_string()),
7783 "AWS_SECRET_ACCESS_KEY" => Some("env_secret".to_string()),
7784 _ => None,
7785 });
7786 match result {
7787 Some(AwsResolvedCredentials::Sigv4 { access_key_id, .. }) => {
7788 assert_eq!(access_key_id, "AKIA_ENV");
7789 }
7790 other => panic!(),
7791 }
7792 }
7793
7794 #[test]
7795 fn test_aws_no_credentials_returns_none() {
7796 let auth = empty_auth();
7797 let result = resolve_aws_credentials_with_env(&auth, |_| -> Option<String> { None });
7798 assert!(result.is_none());
7799 }
7800
7801 #[test]
7802 fn test_aws_empty_bearer_token_skipped() {
7803 let auth = empty_auth();
7804 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7805 "AWS_BEARER_TOKEN_BEDROCK" => Some(" ".to_string()),
7806 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
7807 "AWS_SECRET_ACCESS_KEY" => Some("secret".to_string()),
7808 _ => None,
7809 });
7810 assert!(matches!(result, Some(AwsResolvedCredentials::Sigv4 { .. })));
7811 }
7812
7813 #[test]
7814 fn test_aws_access_key_without_secret_skipped() {
7815 let auth = empty_auth();
7816 let result = resolve_aws_credentials_with_env(&auth, |var| match var {
7817 "AWS_ACCESS_KEY_ID" => Some("AKIA".to_string()),
7818 _ => None,
7819 });
7820 assert!(result.is_none());
7821 }
7822
7823 #[test]
7826 fn test_sap_json_service_key() {
7827 let auth = empty_auth();
7828 let key_json = serde_json::json!({
7829 "clientid": "sap-client",
7830 "clientsecret": "sap-secret",
7831 "url": "https://auth.sap.example.com/oauth/token",
7832 "serviceurls": {
7833 "AI_API_URL": "https://api.ai.sap.example.com"
7834 }
7835 })
7836 .to_string();
7837 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
7838 "AICORE_SERVICE_KEY" => Some(key_json.clone()),
7839 _ => None,
7840 });
7841 assert_eq!(
7842 result,
7843 Some(SapResolvedCredentials {
7844 client_id: "sap-client".to_string(),
7845 client_secret: "sap-secret".to_string(),
7846 token_url: "https://auth.sap.example.com/oauth/token".to_string(),
7847 service_url: "https://api.ai.sap.example.com".to_string(),
7848 })
7849 );
7850 }
7851
7852 #[test]
7853 fn test_sap_individual_env_vars() {
7854 let auth = empty_auth();
7855 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
7856 "SAP_AI_CORE_CLIENT_ID" => Some("env-client".to_string()),
7857 "SAP_AI_CORE_CLIENT_SECRET" => Some("env-secret".to_string()),
7858 "SAP_AI_CORE_TOKEN_URL" => Some("https://token.sap.example.com".to_string()),
7859 "SAP_AI_CORE_SERVICE_URL" => Some("https://service.sap.example.com".to_string()),
7860 _ => None,
7861 });
7862 assert_eq!(
7863 result,
7864 Some(SapResolvedCredentials {
7865 client_id: "env-client".to_string(),
7866 client_secret: "env-secret".to_string(),
7867 token_url: "https://token.sap.example.com".to_string(),
7868 service_url: "https://service.sap.example.com".to_string(),
7869 })
7870 );
7871 }
7872
7873 #[test]
7874 fn test_sap_stored_service_key() {
7875 let dir = tempfile::tempdir().expect("tmpdir");
7876 let mut auth = AuthStorage {
7877 path: dir.path().join("auth.json"),
7878 entries: HashMap::new(),
7879 };
7880 auth.set(
7881 "sap-ai-core",
7882 AuthCredential::ServiceKey {
7883 client_id: Some("stored-id".to_string()),
7884 client_secret: Some("stored-secret".to_string()),
7885 token_url: Some("https://stored-token.sap.com".to_string()),
7886 service_url: Some("https://stored-api.sap.com".to_string()),
7887 },
7888 );
7889 let result = resolve_sap_credentials_with_env(&auth, |_| -> Option<String> { None });
7890 assert_eq!(
7891 result,
7892 Some(SapResolvedCredentials {
7893 client_id: "stored-id".to_string(),
7894 client_secret: "stored-secret".to_string(),
7895 token_url: "https://stored-token.sap.com".to_string(),
7896 service_url: "https://stored-api.sap.com".to_string(),
7897 })
7898 );
7899 }
7900
7901 #[test]
7902 fn test_sap_stored_service_key_accepts_alias_and_case_insensitive_entry() {
7903 let dir = tempfile::tempdir().expect("tmpdir");
7904 let mut auth = AuthStorage {
7905 path: dir.path().join("auth.json"),
7906 entries: HashMap::new(),
7907 };
7908 auth.set(
7909 "SaP",
7910 AuthCredential::ServiceKey {
7911 client_id: Some("alias-id".to_string()),
7912 client_secret: Some("alias-secret".to_string()),
7913 token_url: Some("https://alias-token.sap.com".to_string()),
7914 service_url: Some("https://alias-api.sap.com".to_string()),
7915 },
7916 );
7917 let result = resolve_sap_credentials_with_env(&auth, |_| -> Option<String> { None });
7918 assert_eq!(
7919 result,
7920 Some(SapResolvedCredentials {
7921 client_id: "alias-id".to_string(),
7922 client_secret: "alias-secret".to_string(),
7923 token_url: "https://alias-token.sap.com".to_string(),
7924 service_url: "https://alias-api.sap.com".to_string(),
7925 })
7926 );
7927 }
7928
7929 #[test]
7930 fn test_sap_json_key_wins_over_individual_vars() {
7931 let key_json = serde_json::json!({
7932 "clientid": "json-client",
7933 "clientsecret": "json-secret",
7934 "url": "https://json-token.example.com",
7935 "serviceurls": {"AI_API_URL": "https://json-api.example.com"}
7936 })
7937 .to_string();
7938 let auth = empty_auth();
7939 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
7940 "AICORE_SERVICE_KEY" => Some(key_json.clone()),
7941 "SAP_AI_CORE_CLIENT_ID" => Some("env-client".to_string()),
7942 "SAP_AI_CORE_CLIENT_SECRET" => Some("env-secret".to_string()),
7943 "SAP_AI_CORE_TOKEN_URL" => Some("https://env-token.example.com".to_string()),
7944 "SAP_AI_CORE_SERVICE_URL" => Some("https://env-api.example.com".to_string()),
7945 _ => None,
7946 });
7947 assert_eq!(result.unwrap().client_id, "json-client");
7948 }
7949
7950 #[test]
7951 fn test_sap_incomplete_individual_vars_returns_none() {
7952 let auth = empty_auth();
7953 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
7954 "SAP_AI_CORE_CLIENT_ID" => Some("id".to_string()),
7955 "SAP_AI_CORE_CLIENT_SECRET" => Some("secret".to_string()),
7956 "SAP_AI_CORE_TOKEN_URL" => Some("https://token.example.com".to_string()),
7957 _ => None,
7958 });
7959 assert!(result.is_none());
7960 }
7961
7962 #[test]
7963 fn test_sap_invalid_json_falls_through() {
7964 let auth = empty_auth();
7965 let result = resolve_sap_credentials_with_env(&auth, |var| match var {
7966 "AICORE_SERVICE_KEY" => Some("not-valid-json".to_string()),
7967 "SAP_AI_CORE_CLIENT_ID" => Some("env-id".to_string()),
7968 "SAP_AI_CORE_CLIENT_SECRET" => Some("env-secret".to_string()),
7969 "SAP_AI_CORE_TOKEN_URL" => Some("https://token.example.com".to_string()),
7970 "SAP_AI_CORE_SERVICE_URL" => Some("https://api.example.com".to_string()),
7971 _ => None,
7972 });
7973 assert_eq!(result.unwrap().client_id, "env-id");
7974 }
7975
7976 #[test]
7977 fn test_sap_no_credentials_returns_none() {
7978 let auth = empty_auth();
7979 let result = resolve_sap_credentials_with_env(&auth, |_| -> Option<String> { None });
7980 assert!(result.is_none());
7981 }
7982
7983 #[test]
7984 fn test_sap_json_key_alternate_field_names() {
7985 let key_json = serde_json::json!({
7986 "client_id": "alt-id",
7987 "client_secret": "alt-secret",
7988 "token_url": "https://alt-token.example.com",
7989 "service_url": "https://alt-api.example.com"
7990 })
7991 .to_string();
7992 let creds = parse_sap_service_key_json(&key_json);
7993 assert_eq!(
7994 creds,
7995 Some(SapResolvedCredentials {
7996 client_id: "alt-id".to_string(),
7997 client_secret: "alt-secret".to_string(),
7998 token_url: "https://alt-token.example.com".to_string(),
7999 service_url: "https://alt-api.example.com".to_string(),
8000 })
8001 );
8002 }
8003
8004 #[test]
8005 fn test_sap_json_key_missing_required_field_returns_none() {
8006 let key_json = serde_json::json!({
8007 "clientid": "id",
8008 "url": "https://token.example.com",
8009 "serviceurls": {"AI_API_URL": "https://api.example.com"}
8010 })
8011 .to_string();
8012 assert!(parse_sap_service_key_json(&key_json).is_none());
8013 }
8014
8015 #[test]
8018 fn test_sap_metadata_exists() {
8019 let keys = env_keys_for_provider("sap-ai-core");
8020 assert!(!keys.is_empty(), "sap-ai-core should have env keys");
8021 assert!(keys.contains(&"AICORE_SERVICE_KEY"));
8022 }
8023
8024 #[test]
8025 fn test_sap_alias_resolves() {
8026 let keys = env_keys_for_provider("sap");
8027 assert!(!keys.is_empty(), "sap alias should resolve");
8028 assert!(keys.contains(&"AICORE_SERVICE_KEY"));
8029 }
8030
8031 #[test]
8032 fn test_exchange_sap_access_token_with_client_success() {
8033 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8034 rt.expect("runtime").block_on(async {
8035 let token_response = r#"{"access_token":"sap-access-token"}"#;
8036 let token_url = spawn_json_server(200, token_response);
8037 let client = crate::http::client::Client::new();
8038 let creds = SapResolvedCredentials {
8039 client_id: "sap-client".to_string(),
8040 client_secret: "sap-secret".to_string(),
8041 token_url,
8042 service_url: "https://api.ai.sap.example.com".to_string(),
8043 };
8044
8045 let token = exchange_sap_access_token_with_client(&client, &creds)
8046 .await
8047 .expect("token exchange");
8048 assert_eq!(token, "sap-access-token");
8049 });
8050 }
8051
8052 #[test]
8053 fn test_exchange_sap_access_token_with_client_http_error() {
8054 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8055 rt.expect("runtime").block_on(async {
8056 let token_url = spawn_json_server(401, r#"{"error":"unauthorized"}"#);
8057 let client = crate::http::client::Client::new();
8058 let creds = SapResolvedCredentials {
8059 client_id: "sap-client".to_string(),
8060 client_secret: "sap-secret".to_string(),
8061 token_url,
8062 service_url: "https://api.ai.sap.example.com".to_string(),
8063 };
8064
8065 let err = exchange_sap_access_token_with_client(&client, &creds)
8066 .await
8067 .expect_err("expected HTTP error");
8068 assert!(
8069 err.to_string().contains("HTTP 401"),
8070 "unexpected error: {err}"
8071 );
8072 });
8073 }
8074
8075 #[test]
8076 fn test_exchange_sap_access_token_with_client_invalid_json() {
8077 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8078 rt.expect("runtime").block_on(async {
8079 let token_url = spawn_json_server(200, r#"{"token":"missing-access-token"}"#);
8080 let client = crate::http::client::Client::new();
8081 let creds = SapResolvedCredentials {
8082 client_id: "sap-client".to_string(),
8083 client_secret: "sap-secret".to_string(),
8084 token_url,
8085 service_url: "https://api.ai.sap.example.com".to_string(),
8086 };
8087
8088 let err = exchange_sap_access_token_with_client(&client, &creds)
8089 .await
8090 .expect_err("expected JSON error");
8091 assert!(
8092 err.to_string().contains("invalid JSON"),
8093 "unexpected error: {err}"
8094 );
8095 });
8096 }
8097
8098 #[test]
8101 fn test_proactive_refresh_triggers_within_window() {
8102 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8103 rt.expect("runtime").block_on(async {
8104 let dir = tempfile::tempdir().expect("tmpdir");
8105 let auth_path = dir.path().join("auth.json");
8106
8107 let five_min_from_now = chrono::Utc::now().timestamp_millis() + 5 * 60 * 1000;
8109 let token_response =
8110 r#"{"access_token":"refreshed","refresh_token":"new-ref","expires_in":3600}"#;
8111 let server_url = spawn_json_server(200, token_response);
8112
8113 let mut auth = AuthStorage {
8114 path: auth_path,
8115 entries: HashMap::new(),
8116 };
8117 auth.entries.insert(
8118 "copilot".to_string(),
8119 AuthCredential::OAuth {
8120 access_token: "about-to-expire".to_string(),
8121 refresh_token: "old-ref".to_string(),
8122 expires: five_min_from_now,
8123 token_url: Some(server_url),
8124 client_id: Some("test-client".to_string()),
8125 },
8126 );
8127
8128 let client = crate::http::client::Client::new();
8129 auth.refresh_expired_oauth_tokens_with_client(&client)
8130 .await
8131 .expect("proactive refresh");
8132
8133 match auth.entries.get("copilot").expect("credential") {
8134 AuthCredential::OAuth { access_token, .. } => {
8135 assert_eq!(access_token, "refreshed");
8136 }
8137 other => panic!(),
8138 }
8139 });
8140 }
8141
8142 #[test]
8143 fn test_proactive_refresh_skips_tokens_far_from_expiry() {
8144 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8145 rt.expect("runtime").block_on(async {
8146 let dir = tempfile::tempdir().expect("tmpdir");
8147 let auth_path = dir.path().join("auth.json");
8148
8149 let one_hour_from_now = chrono::Utc::now().timestamp_millis() + 60 * 60 * 1000;
8150
8151 let mut auth = AuthStorage {
8152 path: auth_path,
8153 entries: HashMap::new(),
8154 };
8155 auth.entries.insert(
8156 "copilot".to_string(),
8157 AuthCredential::OAuth {
8158 access_token: "still-good".to_string(),
8159 refresh_token: "ref".to_string(),
8160 expires: one_hour_from_now,
8161 token_url: Some("https://should-not-be-called.example.com/token".to_string()),
8162 client_id: Some("test-client".to_string()),
8163 },
8164 );
8165
8166 let client = crate::http::client::Client::new();
8167 auth.refresh_expired_oauth_tokens_with_client(&client)
8168 .await
8169 .expect("no refresh needed");
8170
8171 match auth.entries.get("copilot").expect("credential") {
8172 AuthCredential::OAuth { access_token, .. } => {
8173 assert_eq!(access_token, "still-good");
8174 }
8175 other => panic!(),
8176 }
8177 });
8178 }
8179
8180 #[test]
8181 fn test_self_contained_refresh_uses_stored_metadata() {
8182 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8183 rt.expect("runtime").block_on(async {
8184 let dir = tempfile::tempdir().expect("tmpdir");
8185 let auth_path = dir.path().join("auth.json");
8186
8187 let token_response =
8188 r#"{"access_token":"new-copilot-token","refresh_token":"new-ref","expires_in":28800}"#;
8189 let server_url = spawn_json_server(200, token_response);
8190
8191 let mut auth = AuthStorage {
8192 path: auth_path,
8193 entries: HashMap::new(),
8194 };
8195 auth.entries.insert(
8196 "copilot".to_string(),
8197 AuthCredential::OAuth {
8198 access_token: "expired-copilot".to_string(),
8199 refresh_token: "old-ref".to_string(),
8200 expires: 0,
8201 token_url: Some(server_url.clone()),
8202 client_id: Some("Iv1.copilot-client".to_string()),
8203 },
8204 );
8205
8206 let client = crate::http::client::Client::new();
8207 auth.refresh_expired_oauth_tokens_with_client(&client)
8208 .await
8209 .expect("self-contained refresh");
8210
8211 match auth.entries.get("copilot").expect("credential") {
8212 AuthCredential::OAuth {
8213 access_token,
8214 token_url,
8215 client_id,
8216 ..
8217 } => {
8218 assert_eq!(access_token, "new-copilot-token");
8219 assert_eq!(token_url.as_deref(), Some(server_url.as_str()));
8220 assert_eq!(client_id.as_deref(), Some("Iv1.copilot-client"));
8221 }
8222 other => panic!(),
8223 }
8224 });
8225 }
8226
8227 #[test]
8228 fn test_self_contained_refresh_skips_when_no_metadata() {
8229 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8230 rt.expect("runtime").block_on(async {
8231 let dir = tempfile::tempdir().expect("tmpdir");
8232 let auth_path = dir.path().join("auth.json");
8233
8234 let mut auth = AuthStorage {
8235 path: auth_path,
8236 entries: HashMap::new(),
8237 };
8238 auth.entries.insert(
8239 "ext-custom".to_string(),
8240 AuthCredential::OAuth {
8241 access_token: "old-ext".to_string(),
8242 refresh_token: "ref".to_string(),
8243 expires: 0,
8244 token_url: None,
8245 client_id: None,
8246 },
8247 );
8248
8249 let client = crate::http::client::Client::new();
8250 auth.refresh_expired_oauth_tokens_with_client(&client)
8251 .await
8252 .expect("should succeed by skipping");
8253
8254 match auth.entries.get("ext-custom").expect("credential") {
8255 AuthCredential::OAuth { access_token, .. } => {
8256 assert_eq!(access_token, "old-ext");
8257 }
8258 other => panic!(),
8259 }
8260 });
8261 }
8262
8263 #[test]
8264 fn test_extension_refresh_skips_self_contained_credentials() {
8265 let rt = asupersync::runtime::RuntimeBuilder::current_thread().build();
8266 rt.expect("runtime").block_on(async {
8267 let dir = tempfile::tempdir().expect("tmpdir");
8268 let auth_path = dir.path().join("auth.json");
8269
8270 let mut auth = AuthStorage {
8271 path: auth_path,
8272 entries: HashMap::new(),
8273 };
8274 auth.entries.insert(
8275 "copilot".to_string(),
8276 AuthCredential::OAuth {
8277 access_token: "self-contained".to_string(),
8278 refresh_token: "ref".to_string(),
8279 expires: 0,
8280 token_url: Some("https://github.com/login/oauth/access_token".to_string()),
8281 client_id: Some("Iv1.copilot".to_string()),
8282 },
8283 );
8284
8285 let client = crate::http::client::Client::new();
8286 let mut extension_configs = HashMap::new();
8287 extension_configs.insert("copilot".to_string(), sample_oauth_config());
8288
8289 auth.refresh_expired_extension_oauth_tokens(&client, &extension_configs)
8290 .await
8291 .expect("should succeed by skipping");
8292
8293 match auth.entries.get("copilot").expect("credential") {
8294 AuthCredential::OAuth { access_token, .. } => {
8295 assert_eq!(access_token, "self-contained");
8296 }
8297 other => panic!(),
8298 }
8299 });
8300 }
8301
8302 #[test]
8303 fn test_prune_stale_credentials_removes_old_expired_without_metadata() {
8304 let dir = tempfile::tempdir().expect("tmpdir");
8305 let auth_path = dir.path().join("auth.json");
8306
8307 let mut auth = AuthStorage {
8308 path: auth_path,
8309 entries: HashMap::new(),
8310 };
8311
8312 let now = chrono::Utc::now().timestamp_millis();
8313 let one_day_ms = 24 * 60 * 60 * 1000;
8314
8315 auth.entries.insert(
8317 "stale-ext".to_string(),
8318 AuthCredential::OAuth {
8319 access_token: "dead".to_string(),
8320 refresh_token: "dead-ref".to_string(),
8321 expires: now - 2 * one_day_ms,
8322 token_url: None,
8323 client_id: None,
8324 },
8325 );
8326
8327 auth.entries.insert(
8329 "copilot".to_string(),
8330 AuthCredential::OAuth {
8331 access_token: "old-copilot".to_string(),
8332 refresh_token: "ref".to_string(),
8333 expires: now - 2 * one_day_ms,
8334 token_url: Some("https://github.com/login/oauth/access_token".to_string()),
8335 client_id: Some("Iv1.copilot".to_string()),
8336 },
8337 );
8338
8339 auth.entries.insert(
8341 "recent-ext".to_string(),
8342 AuthCredential::OAuth {
8343 access_token: "recent".to_string(),
8344 refresh_token: "ref".to_string(),
8345 expires: now - 30 * 60 * 1000, token_url: None,
8347 client_id: None,
8348 },
8349 );
8350
8351 auth.entries.insert(
8353 "anthropic".to_string(),
8354 AuthCredential::ApiKey {
8355 key: "sk-test".to_string(),
8356 },
8357 );
8358
8359 let pruned = auth.prune_stale_credentials(one_day_ms);
8360
8361 assert_eq!(pruned, vec!["stale-ext"]);
8362 assert!(!auth.entries.contains_key("stale-ext"));
8363 assert!(auth.entries.contains_key("copilot"));
8364 assert!(auth.entries.contains_key("recent-ext"));
8365 assert!(auth.entries.contains_key("anthropic"));
8366 }
8367
8368 #[test]
8369 fn test_prune_stale_credentials_no_op_when_all_valid() {
8370 let dir = tempfile::tempdir().expect("tmpdir");
8371 let auth_path = dir.path().join("auth.json");
8372
8373 let mut auth = AuthStorage {
8374 path: auth_path,
8375 entries: HashMap::new(),
8376 };
8377
8378 let far_future = chrono::Utc::now().timestamp_millis() + 3_600_000;
8379 auth.entries.insert(
8380 "ext-prov".to_string(),
8381 AuthCredential::OAuth {
8382 access_token: "valid".to_string(),
8383 refresh_token: "ref".to_string(),
8384 expires: far_future,
8385 token_url: None,
8386 client_id: None,
8387 },
8388 );
8389
8390 let pruned = auth.prune_stale_credentials(24 * 60 * 60 * 1000);
8391 assert!(pruned.is_empty());
8392 assert!(auth.entries.contains_key("ext-prov"));
8393 }
8394
8395 #[test]
8396 fn test_credential_serialization_preserves_new_fields() {
8397 let cred = AuthCredential::OAuth {
8398 access_token: "tok".to_string(),
8399 refresh_token: "ref".to_string(),
8400 expires: 12345,
8401 token_url: Some("https://example.com/token".to_string()),
8402 client_id: Some("my-client".to_string()),
8403 };
8404
8405 let json = serde_json::to_string(&cred).expect("serialize");
8406 assert!(json.contains("token_url"));
8407 assert!(json.contains("client_id"));
8408
8409 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
8410 match parsed {
8411 AuthCredential::OAuth {
8412 token_url,
8413 client_id,
8414 ..
8415 } => {
8416 assert_eq!(token_url.as_deref(), Some("https://example.com/token"));
8417 assert_eq!(client_id.as_deref(), Some("my-client"));
8418 }
8419 other => panic!(),
8420 }
8421 }
8422
8423 #[test]
8424 fn test_credential_serialization_omits_none_fields() {
8425 let cred = AuthCredential::OAuth {
8426 access_token: "tok".to_string(),
8427 refresh_token: "ref".to_string(),
8428 expires: 12345,
8429 token_url: None,
8430 client_id: None,
8431 };
8432
8433 let json = serde_json::to_string(&cred).expect("serialize");
8434 assert!(!json.contains("token_url"));
8435 assert!(!json.contains("client_id"));
8436 }
8437
8438 #[test]
8439 fn test_credential_deserialization_defaults_missing_fields() {
8440 let json =
8441 r#"{"type":"o_auth","access_token":"tok","refresh_token":"ref","expires":12345}"#;
8442 let parsed: AuthCredential = serde_json::from_str(json).expect("deserialize");
8443 match parsed {
8444 AuthCredential::OAuth {
8445 token_url,
8446 client_id,
8447 ..
8448 } => {
8449 assert!(token_url.is_none());
8450 assert!(client_id.is_none());
8451 }
8452 other => panic!(),
8453 }
8454 }
8455
8456 #[test]
8457 fn codex_openai_api_key_parser_ignores_oauth_access_token_only_payloads() {
8458 let value = serde_json::json!({
8459 "tokens": {
8460 "access_token": "codex-oauth-token"
8461 }
8462 });
8463 assert!(codex_openai_api_key_from_value(&value).is_none());
8464 }
8465
8466 #[test]
8467 fn codex_access_token_parser_reads_nested_tokens_payload() {
8468 let value = serde_json::json!({
8469 "tokens": {
8470 "access_token": " codex-oauth-token "
8471 }
8472 });
8473 assert_eq!(
8474 codex_access_token_from_value(&value).as_deref(),
8475 Some("codex-oauth-token")
8476 );
8477 }
8478
8479 #[test]
8480 fn codex_openai_api_key_parser_reads_openai_api_key_field() {
8481 let value = serde_json::json!({
8482 "OPENAI_API_KEY": " sk-openai "
8483 });
8484 assert_eq!(
8485 codex_openai_api_key_from_value(&value).as_deref(),
8486 Some("sk-openai")
8487 );
8488 }
8489
8490 #[test]
8493 fn test_resolve_api_key_source_literal_string() {
8494 let result = resolve_api_key_source_with_env("sk-plain-key-12345", |_| None);
8495 assert_eq!(result.unwrap(), Some("sk-plain-key-12345".to_string()));
8496 }
8497
8498 #[test]
8499 fn test_resolve_api_key_source_empty_literal() {
8500 let result = resolve_api_key_source_with_env("", |_| None);
8501 assert_eq!(result.unwrap(), Some(String::new()));
8502 }
8503
8504 #[test]
8505 fn test_resolve_api_key_source_env_var_resolves() {
8506 let result = resolve_api_key_source_with_env("$ENV:MY_API_KEY", |var| {
8507 assert_eq!(var, "MY_API_KEY");
8508 Some("resolved-secret-key".to_string())
8509 });
8510 assert_eq!(result.unwrap(), Some("resolved-secret-key".to_string()));
8511 }
8512
8513 #[test]
8514 fn test_resolve_api_key_source_env_var_trims_whitespace() {
8515 let result =
8516 resolve_api_key_source_with_env("$ENV:MY_KEY", |_| Some(" spaced-key ".to_string()));
8517 assert_eq!(result.unwrap(), Some("spaced-key".to_string()));
8518 }
8519
8520 #[test]
8521 fn test_resolve_api_key_source_command_resolves() {
8522 let result = resolve_api_key_source_with_resolvers(
8523 "$CMD:pass show openai/api-key",
8524 |_| None,
8525 |command| {
8526 assert_eq!(command, "pass show openai/api-key");
8527 Ok(Some("resolved-command-key".to_string()))
8528 },
8529 );
8530 assert_eq!(result.unwrap(), Some("resolved-command-key".to_string()));
8531 }
8532
8533 #[test]
8534 fn test_resolve_api_key_source_command_alias_prefix_resolves() {
8535 let result = resolve_api_key_source_with_resolvers(
8536 "$COMMAND:op read secret",
8537 |_| None,
8538 |command| {
8539 assert_eq!(command, "op read secret");
8540 Ok(Some("from-command-alias".to_string()))
8541 },
8542 );
8543 assert_eq!(result.unwrap(), Some("from-command-alias".to_string()));
8544 }
8545
8546 #[test]
8547 fn test_resolve_api_key_source_command_trims_whitespace() {
8548 let result = resolve_api_key_source_with_resolvers(
8549 "$CMD:security find-generic-password",
8550 |_| None,
8551 |_| Ok(Some(" spaced-command-key ".to_string())),
8552 );
8553 assert_eq!(result.unwrap(), Some("spaced-command-key".to_string()));
8554 }
8555
8556 #[test]
8557 fn test_resolve_api_key_source_command_empty_returns_none() {
8558 let result = resolve_api_key_source_with_resolvers(
8559 "$CMD:echo key",
8560 |_| None,
8561 |_| Ok(Some(" ".to_string())),
8562 );
8563 assert_eq!(result.unwrap(), None);
8564 }
8565
8566 #[test]
8567 fn test_resolve_api_key_source_command_runner_error_propagates() {
8568 let result = resolve_api_key_source_with_resolvers(
8569 "$CMD:bad-command",
8570 |_| None,
8571 |_| Err("boom".to_string()),
8572 );
8573 assert_eq!(result.unwrap_err(), "boom");
8574 }
8575
8576 #[test]
8577 fn test_resolve_api_key_source_command_prefix_no_command_is_error() {
8578 let result = resolve_api_key_source_with_resolvers(
8579 "$CMD: ",
8580 |_| None,
8581 |_| {
8582 panic!("command runner should not be called for empty command");
8583 },
8584 );
8585 assert!(result.is_err());
8586 assert!(result.unwrap_err().contains("requires a shell command"));
8587 }
8588
8589 #[test]
8590 fn test_resolve_api_key_source_env_var_missing_returns_none() {
8591 let result = resolve_api_key_source_with_env("$ENV:NONEXISTENT_VAR", |_| None);
8592 assert_eq!(result.unwrap(), None);
8593 }
8594
8595 #[test]
8596 fn test_resolve_api_key_source_env_var_empty_returns_none() {
8597 let result = resolve_api_key_source_with_env("$ENV:EMPTY_VAR", |_| Some(String::new()));
8598 assert_eq!(result.unwrap(), None);
8599 }
8600
8601 #[test]
8602 fn test_resolve_api_key_source_env_var_whitespace_only_returns_none() {
8603 let result = resolve_api_key_source_with_env("$ENV:WS_ONLY", |_| Some(" ".to_string()));
8604 assert_eq!(result.unwrap(), None);
8605 }
8606
8607 #[test]
8608 fn test_resolve_api_key_source_env_prefix_no_var_name_is_error() {
8609 let result = resolve_api_key_source_with_env("$ENV:", |_| None);
8610 assert!(result.is_err());
8611 assert!(result.unwrap_err().contains("requires a variable name"));
8612 }
8613
8614 #[test]
8615 fn test_resolve_api_key_source_dollar_without_env_is_literal() {
8616 let result = resolve_api_key_source_with_env("$NOTENV:FOO", |_| {
8618 panic!("should not call env_lookup for non-$ENV: prefixed keys");
8619 });
8620 assert_eq!(result.unwrap(), Some("$NOTENV:FOO".to_string()));
8621 }
8622
8623 #[test]
8624 fn test_resolve_api_key_source_case_sensitive_prefix() {
8625 let result = resolve_api_key_source_with_env("$env:MY_KEY", |_| {
8627 panic!("should not call env_lookup for lowercase $env:");
8628 });
8629 assert_eq!(result.unwrap(), Some("$env:MY_KEY".to_string()));
8630 }
8631
8632 #[test]
8633 fn test_api_key_from_credential_resolves_env_var() {
8634 let result = resolve_api_key_source_with_env("$ENV:MY_SECRET_KEY", |var| {
8637 assert_eq!(var, "MY_SECRET_KEY");
8638 Some("from-env-42".to_string())
8639 });
8640 assert_eq!(result.unwrap(), Some("from-env-42".to_string()));
8641 }
8642
8643 #[test]
8644 fn test_api_key_from_credential_literal_backward_compat() {
8645 let cred = AuthCredential::ApiKey {
8646 key: "sk-ant-plain-key".to_string(),
8647 };
8648 let result = api_key_from_credential(&cred);
8649 assert_eq!(result, Some("sk-ant-plain-key".to_string()));
8650 }
8651
8652 #[test]
8653 fn test_api_key_from_credential_env_var_missing_returns_none() {
8654 let result = resolve_api_key_source_with_env("$ENV:DEFINITELY_NOT_SET", |_| None);
8655 assert_eq!(result.unwrap(), None);
8656 }
8657
8658 #[test]
8659 fn test_env_key_roundtrip_serialization() {
8660 let cred = AuthCredential::ApiKey {
8661 key: "$ENV:OPENAI_API_KEY".to_string(),
8662 };
8663 let json = serde_json::to_string(&cred).expect("serialize");
8664 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
8665 match parsed {
8666 AuthCredential::ApiKey { key } => {
8667 assert_eq!(key, "$ENV:OPENAI_API_KEY");
8668 }
8669 other => panic!("expected ApiKey, got {:?}", other),
8670 }
8671 }
8672
8673 #[test]
8674 fn test_command_key_roundtrip_serialization() {
8675 let cred = AuthCredential::ApiKey {
8676 key: "$CMD:op read op://vault/openai/api-key --no-newline".to_string(),
8677 };
8678 let json = serde_json::to_string(&cred).expect("serialize");
8679 let parsed: AuthCredential = serde_json::from_str(&json).expect("deserialize");
8680 match parsed {
8681 AuthCredential::ApiKey { key } => {
8682 assert_eq!(key, "$CMD:op read op://vault/openai/api-key --no-newline");
8683 }
8684 other => panic!("expected ApiKey, got {:?}", other),
8685 }
8686 }
8687
8688 #[test]
8689 fn test_env_key_in_auth_file_roundtrip() {
8690 let dir = tempfile::tempdir().expect("tmpdir");
8691 let auth_path = dir.path().join("auth.json");
8692 let mut auth = AuthStorage {
8693 path: auth_path.clone(),
8694 entries: HashMap::new(),
8695 };
8696 auth.set(
8697 "openai",
8698 AuthCredential::ApiKey {
8699 key: "$ENV:OPENAI_API_KEY".to_string(),
8700 },
8701 );
8702 auth.save().expect("save");
8703
8704 let loaded = AuthStorage::load(auth_path).expect("load");
8705 match loaded.get("openai") {
8706 Some(AuthCredential::ApiKey { key }) => {
8707 assert_eq!(key, "$ENV:OPENAI_API_KEY");
8708 }
8709 other => panic!("expected ApiKey with $ENV: prefix, got {:?}", other),
8710 }
8711 }
8712
8713 #[test]
8714 fn test_resolve_api_key_env_source_integration() {
8715 let dir = tempfile::tempdir().expect("tmpdir");
8716 let auth_path = dir.path().join("auth.json");
8717 let mut auth = AuthStorage {
8718 path: auth_path,
8719 entries: HashMap::new(),
8720 };
8721 auth.set(
8722 "openai",
8723 AuthCredential::ApiKey {
8724 key: "$ENV:PI_TEST_OPENAI_KEY_INTEG".to_string(),
8725 },
8726 );
8727
8728 let resolved_without_env = auth.resolve_api_key_with_env_lookup("openai", None, |_| None);
8731 assert!(
8732 resolved_without_env.is_none(),
8733 "env-backed key with unset var and no env fallback should resolve to None"
8734 );
8735
8736 let resolved_with_override =
8738 auth.resolve_api_key_with_env_lookup("openai", Some("override-key"), |_| None);
8739 assert_eq!(resolved_with_override.as_deref(), Some("override-key"));
8740 }
8741}