Skip to main content

pi/
auth.rs

1//! Authentication storage and API key resolution.
2//!
3//! Auth file: ~/.pi/agent/auth.json
4
5use 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
26// ── OpenAI Codex OAuth constants ─────────────────────────────────
27const 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
33// ── Google Gemini CLI OAuth constants ────────────────────────────
34const 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
43// ── Google Antigravity OAuth constants ───────────────────────────
44const 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
57/// Internal marker used to preserve OAuth-vs-API-key lane information when
58/// passing Anthropic credentials through provider-agnostic key plumbing.
59const ANTHROPIC_OAUTH_BEARER_MARKER: &str = "__pi_anthropic_oauth_bearer__:";
60
61// ── GitHub / Copilot OAuth constants ──────────────────────────────
62const 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";
65/// Default scopes for Copilot access (read:user needed for identity).
66const GITHUB_COPILOT_SCOPES: &str = "read:user";
67
68// ── GitLab OAuth constants ────────────────────────────────────────
69const GITLAB_OAUTH_AUTHORIZE_PATH: &str = "/oauth/authorize";
70const GITLAB_OAUTH_TOKEN_PATH: &str = "/oauth/token";
71const GITLAB_DEFAULT_BASE_URL: &str = "https://gitlab.com";
72/// Default scopes for GitLab AI features.
73const GITLAB_DEFAULT_SCOPES: &str = "api read_api read_user";
74
75// ── Kimi Code OAuth constants ─────────────────────────────────────
76const 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
83// ── OAuth env-var override helper ───────────────────────────────
84//
85// Every compiled-in OAuth constant can be overridden at runtime via an
86// environment variable.  This lets operators update credentials without
87// rebuilding the binary if a provider rotates client IDs or URLs.
88
89/// Read an OAuth parameter from the environment, falling back to the
90/// compiled-in default when the env var is absent or empty.
91fn 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
101// Accessor functions for each overridable OAuth constant.  Call these
102// instead of using the `const` values directly so the env-var escape
103// hatch is always active.
104
105fn 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/// Credentials stored in auth.json.
204#[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, // Unix ms
214        /// Token endpoint URL for self-contained refresh (optional; backward-compatible).
215        #[serde(default, skip_serializing_if = "Option::is_none")]
216        token_url: Option<String>,
217        /// Client ID for self-contained refresh (optional; backward-compatible).
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        client_id: Option<String>,
220    },
221    /// AWS IAM credentials for providers like Amazon Bedrock.
222    ///
223    /// Supports the standard credential chain: explicit keys → env vars → profile → container
224    /// credentials → web identity token.
225    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    /// Bearer token for providers that accept `Authorization: Bearer <token>`.
234    ///
235    /// Used by gateway proxies (Vercel AI Gateway, Helicone, etc.) and services
236    /// that issue pre-authenticated bearer tokens (e.g. `AWS_BEARER_TOKEN_BEDROCK`).
237    BearerToken {
238        token: String,
239    },
240    /// Service key credentials for providers like SAP AI Core that use
241    /// client-credentials OAuth (client_id + client_secret → token_url → bearer).
242    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    /// Preserves malformed or legacy credential entries during migration/saving
253    /// so that we don't drop them if the schema evolves.
254    #[serde(untagged)]
255    Unknown(serde_json::Value),
256}
257
258/// Canonical credential status for a provider in auth.json.
259#[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
270/// Proactive refresh: attempt refresh this many ms *before* actual expiry.
271/// This avoids using a token that's about to expire during a long-running request.
272const PROACTIVE_REFRESH_WINDOW_MS: i64 = 10 * 60 * 1000; // 10 minutes
273type 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/// Auth storage wrapper with file locking.
288#[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        // External credential auto-detection is intended for Pi's global auth
297        // file (typically `~/.pi/agent/auth.json`). Scoping it this way keeps
298        // tests and custom auth sandboxes deterministic.
299        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    /// Load auth.json (creates empty if missing).
334    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    /// Load auth.json asynchronously (creates empty if missing).
370    pub async fn load_async(path: PathBuf) -> Result<Self> {
371        asupersync::runtime::spawn_blocking(move || Self::load(path)).await
372    }
373
374    /// Persist auth.json (atomic write + permissions).
375    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    /// Persist auth.json asynchronously.
383    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    /// Get raw credential.
426    pub fn get(&self, provider: &str) -> Option<&AuthCredential> {
427        self.entries.get(provider)
428    }
429
430    /// Insert or replace a credential for a provider.
431    pub fn set(&mut self, provider: impl Into<String>, credential: AuthCredential) {
432        self.entries.insert(provider.into(), credential);
433    }
434
435    /// Remove a credential for a provider.
436    pub fn remove(&mut self, provider: &str) -> bool {
437        self.entries.remove(provider).is_some()
438    }
439
440    /// Get API key for provider from auth.json.
441    ///
442    /// For `ApiKey` and `BearerToken` variants the key/token is returned directly.
443    /// For `OAuth` the access token is returned only when not expired.
444    /// For `AwsCredentials` the access key ID is returned (callers needing the full
445    /// credential set should use [`get`] instead).
446    /// For `ServiceKey` this returns `None` because a token exchange is required first.
447    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    /// Return the names of all providers that have stored credentials.
453    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    /// Return stored credential status for a provider, including canonical alias fallback.
460    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    /// Remove stored credentials for `provider` and any known aliases/canonical IDs.
492    ///
493    /// Matching is case-insensitive to clean up legacy mixed-case auth entries.
494    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    /// Returns true when auth.json contains a credential for `provider`
527    /// (including canonical alias fallback).
528    pub fn has_stored_credential(&self, provider: &str) -> bool {
529        self.credential_for_provider(provider).is_some()
530    }
531
532    /// Return a human-readable source label when credentials can be auto-detected
533    /// from other locally-installed coding CLIs.
534    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    /// Resolve API key with precedence.
542    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        // Prefer explicit stored OAuth/Bearer credentials over ambient env vars.
563        // This prevents stale shell env keys from silently overriding successful `/login` flows.
564        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    /// Refresh any expired OAuth tokens that this binary knows how to refresh.
616    ///
617    /// This keeps startup behavior predictable: models that rely on OAuth credentials remain
618    /// available after restart without requiring the user to re-login.
619    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    /// Refresh any expired OAuth tokens using the provided HTTP client.
625    ///
626    /// This is primarily intended for tests and deterministic harnesses (e.g. VCR playback),
627    /// but is also useful for callers that want to supply a custom HTTP implementation.
628    #[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                // Proactive refresh: refresh if the token will expire within the
648                // proactive window, not just when already expired.
649                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                        // Should have been filtered out or handled by extensions logic, but safe to ignore.
719                        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 an error to signal that at least some refreshes failed,
744            // but only after attempting all of them.
745            return Err(Error::auth(format!(
746                "OAuth token refresh failed for: {}",
747                failed_providers.join(", ")
748            )));
749        }
750
751        Ok(())
752    }
753
754    /// Refresh expired OAuth tokens for extension-registered providers.
755    ///
756    /// `extension_configs` maps provider ID to its [`OAuthConfig`](crate::models::OAuthConfig).
757    /// Providers already handled by `refresh_expired_oauth_tokens_with_client` (e.g. "anthropic")
758    /// are skipped.
759    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                // Skip built-in providers (handled by refresh_expired_oauth_tokens_with_client).
778                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                // Skip self-contained credentials — they are refreshed by
789                // refresh_expired_oauth_tokens_with_client instead.
790                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    /// Remove OAuth credentials that expired more than `max_age_ms` ago and
856    /// whose refresh token is no longer usable (no stored `token_url`/`client_id`
857    /// and no matching extension config).
858    ///
859    /// Returns the list of pruned provider IDs.
860    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                // Only prune tokens that are well past expiry AND have no
874                // self-contained refresh metadata.
875                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
893/// Resolve an API key string that may contain a `$ENV:VAR_NAME` or
894/// `$CMD:shell command` prefix.
895///
896/// - Plain string → returned as-is (backward compatible literal key)
897/// - `$ENV:VAR_NAME` → resolves the named environment variable at runtime
898/// - `$CMD:shell command` / `$COMMAND:shell command` → runs the command and
899///   uses trimmed stdout as the key
900///
901/// Returns `None` when the key references an env var that is unset or empty.
902/// Returns `None` when the command succeeds but produces empty output.
903/// Returns `Err` with a descriptive message for malformed `$ENV:` / `$CMD:`
904/// references or command execution failures.
905fn 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
913/// Testable version of [`resolve_api_key_source`] with injectable env lookup.
914fn 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    // Plain literal key — backward compatible.
980    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        // Keep OpenAI API-key auth distinct from Codex OAuth token auth.
1088        // Codex access tokens are only valid on Codex-specific routes.
1089        "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
1106/// Return a stable human-readable label when we can auto-detect local credentials
1107/// from another coding agent installation.
1108pub 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        // Canonical codex CLI shape.
1187        value
1188            .get("tokens")
1189            .and_then(|tokens| tokens.get("access_token"))
1190            .and_then(serde_json::Value::as_str),
1191        // CamelCase variant.
1192        value
1193            .get("tokens")
1194            .and_then(|tokens| tokens.get("accessToken"))
1195            .and_then(serde_json::Value::as_str),
1196        // Flat variants.
1197        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// ── AWS Credential Chain ────────────────────────────────────────
1440
1441/// Resolved AWS credentials ready for Sigv4 signing or bearer auth.
1442#[derive(Debug, Clone, PartialEq, Eq)]
1443pub enum AwsResolvedCredentials {
1444    /// Standard IAM credentials for Sigv4 signing.
1445    Sigv4 {
1446        access_key_id: String,
1447        secret_access_key: String,
1448        session_token: Option<String>,
1449        region: String,
1450    },
1451    /// Bearer token (e.g. `AWS_BEARER_TOKEN_BEDROCK`).
1452    Bearer { token: String, region: String },
1453}
1454
1455/// Resolve AWS credentials following the standard precedence chain.
1456///
1457/// Precedence (first match wins):
1458/// 1. `AWS_BEARER_TOKEN_BEDROCK` env var → bearer token auth
1459/// 2. `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` env vars → Sigv4
1460/// 3. `AWS_PROFILE` env var → profile-based credentials from AWS config files
1461/// 4. Stored `AwsCredentials` in auth.json
1462/// 5. Stored `BearerToken` in auth.json (for bedrock)
1463///
1464/// `region` is resolved from: `AWS_REGION` → `AWS_DEFAULT_REGION` → `"us-east-1"`.
1465pub 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    // 1. Bearer token from env (AWS Bedrock specific)
1485    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    // 2. Explicit IAM credentials from env
1493    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    // 3. Profile-based credentials (AWS_PROFILE / AWS_DEFAULT_PROFILE)
1514    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                &region,
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    // 4. Stored credentials in auth.json
1534    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            // Legacy: treat stored API key as bearer token for Bedrock.
1553            // Supports $ENV: and $CMD: prefixes for dynamically resolved keys.
1554            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// ── SAP AI Core Service Key Resolution ──────────────────────────
1744
1745/// Resolved SAP AI Core credentials ready for client-credentials token exchange.
1746#[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
1754/// Resolve SAP AI Core credentials from env vars or stored service key.
1755///
1756/// Precedence:
1757/// 1. `AICORE_SERVICE_KEY` env var (JSON-encoded service key)
1758/// 2. Individual env vars: `SAP_AI_CORE_CLIENT_ID`, `SAP_AI_CORE_CLIENT_SECRET`,
1759///    `SAP_AI_CORE_TOKEN_URL`, `SAP_AI_CORE_SERVICE_URL`
1760/// 3. Stored `ServiceKey` in auth.json
1761pub 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    // 1. JSON-encoded service key from env
1773    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    // 2. Individual env vars
1780    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    // 3. Stored service key in auth.json
1803    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
1831/// Parse a JSON-encoded SAP AI Core service key.
1832fn 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    // SAP service keys use "clientid"/"clientsecret" (no underscore) and
1837    // "url" for token URL, "serviceurls.AI_API_URL" for service URL.
1838    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
1873/// Exchange SAP AI Core service-key credentials for an access token.
1874///
1875/// Returns `Ok(None)` when SAP credentials are not configured.
1876pub 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    /// The redirect URI used in the authorization request.
2009    /// When this points to localhost, a local callback server should be started.
2010    pub redirect_uri: Option<String>,
2011    /// Pre-bound callback server (already listening).
2012    ///
2013    /// When a provider binds a random-port localhost listener at start time
2014    /// (e.g. GitHub Copilot, GitLab), the server is stored here so the caller
2015    /// does not need to bind a second time.  If `Some`, the caller should use
2016    /// this server directly instead of calling [`start_oauth_callback_server`].
2017    pub callback_server: Option<OAuthCallbackServer>,
2018}
2019
2020// ── Local OAuth callback server ─────────────────────────────────
2021
2022/// Handle for a background TCP listener that receives the OAuth redirect callback.
2023///
2024/// When the OAuth provider redirects the browser to a `localhost` URI, this
2025/// server accepts a single connection, extracts the full request URL (which
2026/// contains the `code` and `state` query parameters), sends a success HTML
2027/// page to the browser, and delivers the URL through the returned receiver.
2028pub struct OAuthCallbackServer {
2029    /// Receives the full request path+query (e.g. `/auth/callback?code=abc&state=xyz`).
2030    pub rx: std::sync::mpsc::Receiver<String>,
2031    /// The port the server is listening on (for logging/diagnostics).
2032    pub port: u16,
2033    // Note: `_handle` is kept for the thread join handle, which keeps the
2034    // listener thread alive until this struct is dropped.
2035    _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
2046/// Start a local TCP listener on the port specified in `redirect_uri`.
2047///
2048/// The server accepts exactly one connection, responds with a friendly HTML page
2049/// so the browser shows "You can close this tab", and sends the full callback URL
2050/// through the returned `OAuthCallbackServer.rx`.
2051///
2052/// Returns `Err` if the redirect URI does not contain a parseable port or the
2053/// port cannot be bound.
2054pub fn start_oauth_callback_server(redirect_uri: &str) -> Result<OAuthCallbackServer> {
2055    // Parse the port from the redirect URI (e.g. "http://localhost:1455/auth/callback").
2056    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    // Set a generous timeout so the thread doesn't hang forever if the user
2069    // cancels. The thread will exit when the listener is dropped or when a
2070    // connection arrives.
2071    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        // Accept exactly one connection.
2079        let Ok((mut stream, _addr)) = listener.accept() else {
2080            return;
2081        };
2082        let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
2083
2084        // Read the HTTP request (we only need the first line: `GET /path?query HTTP/1.1`).
2085        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                // "GET /auth/callback?code=abc&state=xyz HTTP/1.1"
2096                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        // Send a friendly response so the browser shows a success page.
2106        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">&#10003; 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        // Deliver the callback URL to the waiting caller.
2125        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
2139/// Extract the port number from a localhost redirect URI.
2140///
2141/// Supports formats like `http://localhost:1455/auth/callback` and
2142/// `http://127.0.0.1:8085/oauth2callback`.
2143fn parse_port_from_uri(uri: &str) -> Option<u16> {
2144    // Strip scheme
2145    let without_scheme = uri
2146        .strip_prefix("http://")
2147        .or_else(|| uri.strip_prefix("https://"))?;
2148    // Take host:port part (before the path)
2149    let host_port = without_scheme.split('/').next()?;
2150    // Extract port after the last colon
2151    let port_str = host_port.rsplit(':').next()?;
2152    port_str.parse::<u16>().ok()
2153}
2154
2155/// Returns `true` if this redirect URI points to localhost (and therefore
2156/// needs a local callback server).
2157pub 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
2162/// Start a local TCP listener on a random available port and return both the
2163/// callback server and the `http://localhost:{port}/callback` redirect URI.
2164///
2165/// This is useful for OAuth providers (like GitHub Copilot and GitLab) that
2166/// accept localhost redirect URIs but don't have a fixed port pre-registered.
2167/// The caller should include the returned `redirect_uri` in the authorization
2168/// URL query parameters.
2169pub 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">&#10003; 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// ── Device Flow (RFC 8628) ──────────────────────────────────────
2254
2255/// Response from the device authorization endpoint (RFC 8628 section 3.2).
2256#[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/// Result of polling the device flow token endpoint.
2273#[derive(Debug)]
2274pub enum DeviceFlowPollResult {
2275    /// User has not yet authorized; keep polling.
2276    Pending,
2277    /// Server asked us to slow down; increase interval.
2278    SlowDown,
2279    /// Authorization succeeded.
2280    Success(AuthCredential),
2281    /// Device code has expired.
2282    Expired,
2283    /// User explicitly denied access.
2284    AccessDenied,
2285    /// An unexpected error occurred.
2286    Error(String),
2287}
2288
2289// ── Provider-specific OAuth configs ─────────────────────────────
2290
2291/// OAuth settings for GitHub Copilot.
2292///
2293/// `github_base_url` defaults to `https://github.com` but can be overridden
2294/// for GitHub Enterprise Server instances.
2295#[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/// OAuth settings for GitLab.
2313///
2314/// `base_url` defaults to `https://gitlab.com` but can be overridden
2315/// for self-hosted GitLab instances.
2316#[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
2634/// Start Anthropic OAuth by generating an authorization URL and PKCE verifier.
2635pub 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
2669/// Complete Anthropic OAuth by exchanging an authorization code for access/refresh tokens.
2670pub 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
2767/// Start OpenAI Codex OAuth by generating an authorization URL and PKCE verifier.
2768pub 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
2803/// Complete OpenAI Codex OAuth by exchanging an authorization code for access/refresh tokens.
2804pub 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
2864/// Start Google Gemini CLI OAuth by generating an authorization URL and PKCE verifier.
2865pub 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
2897/// Start Google Antigravity OAuth by generating an authorization URL and PKCE verifier.
2898pub 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
3081/// Complete Google Gemini CLI OAuth by exchanging an authorization code for tokens.
3082pub 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
3122/// Complete Google Antigravity OAuth by exchanging an authorization code for tokens.
3123pub 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
3260/// Start Kimi Code OAuth device flow.
3261pub 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
3301/// Poll Kimi Code OAuth device flow.
3302pub 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
3435/// Start OAuth for an extension-registered provider using its [`OAuthConfig`](crate::models::OAuthConfig).
3436pub 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, &params);
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
3472/// Complete OAuth for an extension-registered provider by exchanging an authorization code.
3473pub 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
3534/// Refresh an OAuth token for an extension-registered provider.
3535async 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
3575/// Provider-agnostic OAuth refresh using self-contained credential metadata.
3576///
3577/// This is called for providers whose [`AuthCredential::OAuth`] stores its own
3578/// `token_url` and `client_id` (e.g. Copilot, GitLab), removing the need for
3579/// an external config lookup at refresh time.
3580async 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
3622// ── GitHub Copilot OAuth ─────────────────────────────────────────
3623
3624/// Start GitHub Copilot OAuth using the browser-based authorization code flow.
3625///
3626/// For CLI tools the device flow ([`start_copilot_device_flow`]) is usually
3627/// preferred, but the browser flow is provided for environments that support
3628/// redirect callbacks.
3629pub 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    // Bind a localhost callback server on a random port so the browser redirect
3650    // is captured automatically (issue #22).  GitHub OAuth supports any
3651    // localhost redirect URI for public OAuth Apps.
3652    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, &params);
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        // Pre-bound callback server — caller should use this instead of
3681        // starting a new one (the port is already bound).
3682        callback_server: callback.map(|(server, _)| server),
3683    })
3684}
3685
3686/// Complete the GitHub Copilot browser OAuth flow by exchanging the authorization code.
3687pub 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    // RFC 6749 §4.1.3: redirect_uri MUST be included in the token request
3725    // if it was included in the authorization request.
3726    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    // Attach refresh metadata so the credential is self-contained for lifecycle refresh.
3754    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
3766/// Start the GitHub device flow (RFC 8628) for Copilot.
3767///
3768/// Returns a [`DeviceCodeResponse`] containing the `user_code` and
3769/// `verification_uri` the user should visit.
3770pub 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
3822/// Poll the GitHub device flow token endpoint.
3823///
3824/// Call this repeatedly at the interval specified in [`DeviceCodeResponse`]
3825/// until the result is not [`DeviceFlowPollResult::Pending`].
3826pub 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    // GitHub returns 200 even for pending/error states with an "error" field.
3863    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
3891/// Parse GitHub's token endpoint response into an [`AuthCredential`].
3892///
3893/// GitHub may return `expires_in` (if token has expiry) or omit it for
3894/// non-expiring tokens. Non-expiring tokens use a far-future expiry.
3895fn 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    // GitHub may not return a refresh_token for all grant types.
3906    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                // No expiry → treat as 1 year (GitHub personal access tokens don't expire).
3918                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/client_id are set by the caller (start/complete functions)
3928        // since parse_github_token_response doesn't know the config context.
3929        token_url: None,
3930        client_id: None,
3931    })
3932}
3933
3934/// Build an actionable diagnostic message for Copilot OAuth failures.
3935fn 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
3946// ── GitLab OAuth ────────────────────────────────────────────────
3947
3948/// Start GitLab OAuth using the authorization code flow with PKCE.
3949///
3950/// Supports both `gitlab.com` and self-hosted instances via
3951/// [`GitLabOAuthConfig::base_url`].
3952pub 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    // If no redirect_uri is configured, bind a random-port localhost callback
3966    // server so the browser redirect is captured automatically (issue #22).
3967    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, &params);
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
4005/// Complete GitLab OAuth by exchanging the authorization code for tokens.
4006pub 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
4084/// Build an actionable diagnostic message for GitLab OAuth failures.
4085fn 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
4096// ── Handoff contract to bd-3uqg.7.6 ────────────────────────────
4097//
4098// **OAuth lifecycle boundary**: This module handles the *bootstrap* phase:
4099//   - Initial device flow or browser-based authorization
4100//   - Authorization code → token exchange
4101//   - First credential persistence to auth.json
4102//
4103// **NOT handled here** (owned by bd-3uqg.7.6):
4104//   - Periodic token refresh for Copilot/GitLab
4105//   - Token rotation and re-authentication on refresh failure
4106//   - Cache hygiene (pruning expired entries)
4107//   - Session token lifecycle (keep-alive, invalidation)
4108//
4109// To integrate refresh, add "github-copilot" and "gitlab" arms to
4110// `refresh_expired_oauth_tokens_with_client()` once their refresh
4111// endpoints and grant types are wired.
4112
4113fn 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) => {} // Lock held by another process, retry
4183            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) => {} // Lock held by another process exclusively, retry
4212            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
4234/// A file handle with an exclusive lock. Unlocks on drop.
4235struct 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
4251/// Convenience to load auth from default path.
4252pub 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        // scope param still present but empty string
4868        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        // Verifier should be base64url without padding
4877        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        // Verify that the extension refresh method skips "anthropic" (handled separately).
4922        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            // Insert an expired anthropic OAuth credential.
4931            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, // expired
4939                    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            // Should succeed and NOT attempt refresh (anthropic is skipped).
4949            let result = auth
4950                .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
4951                .await;
4952            assert!(result.is_ok());
4953
4954            // Credential should remain unchanged.
4955            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            // Insert a NOT expired credential.
4977            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            // Credential should remain unchanged (not expired, no refresh attempted).
5001            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            // Expired credential for a provider not in extension_configs.
5023            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(); // empty
5038
5039            let result = auth
5040                .refresh_expired_extension_oauth_tokens(&client, &extension_configs)
5041                .await;
5042            assert!(result.is_ok());
5043
5044            // Credential should remain unchanged (no config to refresh with).
5045            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        // Save OAuth credential.
5250        {
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        // Load and verify.
5269        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, // expired
5332                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")); // already removed
5535    }
5536
5537    #[test]
5538    fn test_auth_env_key_returns_none_for_extension_providers() {
5539        // Extension providers don't have hard-coded env vars.
5540        assert!(env_key_for_provider("my-ext-provider").is_none());
5541        assert!(env_key_for_provider("custom-llm").is_none());
5542        // Built-in providers do.
5543        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        // The URL should be valid and contain encoded values.
5583        assert!(info.url.contains("client%20with%20spaces"));
5584        assert!(info.url.contains("scope%26dangerous"));
5585        assert!(info.url.contains("call%20back"));
5586    }
5587
5588    // ── AuthStorage creation (additional edge cases) ─────────────────
5589
5590    #[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        // Corrupted JSON falls through to `unwrap_or_default()`.
5613        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    // ── resolve_api_key edge cases ───────────────────────────────────
5627
5628    #[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        // Empty string override should not mask stored credentials.
5644        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        // The new precedence is: override > env > stored.
5651        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        // Empty env var is filtered out, falls through to stored.
5731        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    // ── API key storage and persistence ───────────────────────────────
5938
5939    #[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        // Reload and verify all three.
6020        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    // ── Missing key handling ──────────────────────────────────────────
6131
6132    #[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    // ── env_keys_for_provider coverage ────────────────────────────────
6155
6156    #[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    // ── Percent encoding / decoding ───────────────────────────────────
6254
6255    #[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    // ── parse_query_pairs ─────────────────────────────────────────────
6314
6315    #[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    // ── build_url_with_query ──────────────────────────────────────────
6352
6353    #[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    // ── parse_oauth_code_input edge cases ─────────────────────────────
6377
6378    #[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    // ── oauth_expires_at_ms ───────────────────────────────────────────
6422
6423    #[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; // 1 hour
6427        let result = oauth_expires_at_ms(expires_in);
6428
6429        // Should be ~55 minutes from now (3600s - 5min safety margin).
6430        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        // Should be 5 minutes before now (0s - 5min safety margin).
6441        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    // ── Overwrite semantics ───────────────────────────────────────────
6459
6460    #[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        // Save first version.
6493        {
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        // Overwrite.
6508        {
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        // Verify.
6520        let loaded = AuthStorage::load(auth_path).expect("load");
6521        assert_eq!(loaded.api_key("anthropic").as_deref(), Some("new-key"));
6522    }
6523
6524    // ── load_default_auth convenience ─────────────────────────────────
6525
6526    #[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    // ── redact_known_secrets ─────────────────────────────────────────
6548
6549    #[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        // Empty secret should be skipped; only non-empty "   " gets replaced if present.
6562        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    // ── PKCE determinism ──────────────────────────────────────────────
6598
6599    #[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    // ── GitHub Copilot OAuth tests ────────────────────────────────
6616
6617    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            // Extract port from token_url to build a matching config.
6723            let _config = CopilotOAuthConfig {
6724                client_id: "Iv1.test".to_string(),
6725                // Use a base URL that generates the test server URL.
6726                github_base_url: token_url.trim_end_matches("/token").replace("/token", ""),
6727                scopes: "read:user".to_string(),
6728            };
6729
6730            // We need to call complete directly with the token URL.
6731            // Since the function constructs the URL from base, we use an
6732            // alternate approach: test parse_github_token_response directly.
6733            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                // Should be close to 1 year from now (minus 5min safety margin).
6785                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    // ── Device flow tests ─────────────────────────────────────────
6811
6812    #[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    // ── GitLab OAuth tests ────────────────────────────────────────
7048
7049    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        // When no redirect_uri is configured, a random-port localhost callback
7151        // server should be auto-created so the browser redirect is captured.
7152        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        // The auto-generated localhost redirect_uri should be present in the
7165        // authorize URL and the callback_server should be pre-bound.
7166        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            // Test via the token response directly (GitLab uses standard OAuth response).
7205            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            // Also ensure the test server URL was consumed (not left hanging).
7233            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    // ── Provider metadata integration ─────────────────────────────
7257
7258    #[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    // ── Default config constructors ───────────────────────────────
7277
7278    #[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    // ── trim_trailing_slash ───────────────────────────────────────
7296
7297    #[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    // ── AuthCredential new variant serialization ─────────────────────
7322
7323    #[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    // ── api_key() with new variants ──────────────────────────────────
7432
7433    #[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    // ── AWS Credential Chain ─────────────────────────────────────────
7491
7492    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    // ── SAP AI Core Credential Chain ─────────────────────────────────
7824
7825    #[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    // ── SAP AI Core metadata ─────────────────────────────────────────
8016
8017    #[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    // ── Lifecycle tests (bd-3uqg.7.6) ─────────────────────────────
8099
8100    #[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            // Token expires 5 minutes from now (within the 10-min window).
8108            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        // Stale: expired 2 days ago, no metadata.
8316        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        // Not stale: expired 2 days ago but HAS metadata.
8328        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        // Not stale: expired recently.
8340        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, // 30 min ago
8346                token_url: None,
8347                client_id: None,
8348            },
8349        );
8350
8351        // Not OAuth.
8352        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    // ── ApiKeySource resolution tests ───────────────────────────────
8491
8492    #[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        // "$NOTENV:FOO" is NOT a special prefix — only "$ENV:" is.
8617        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        // "$env:FOO" (lowercase) should NOT trigger env resolution — only "$ENV:" is valid.
8626        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        // Test env var resolution through the testable `resolve_api_key_source_with_env` function,
8635        // since the crate forbids unsafe code (and `std::env::set_var` is unsafe).
8636        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        // Without the env var set, stored key should resolve to None, then fall
8729        // through the resolution chain.
8730        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        // With override, override always wins regardless of stored key format.
8737        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}