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