Skip to main content

agentzero_auth/
lib.rs

1//! Credential management for AgentZero.
2//!
3//! Handles API key storage and authentication profiles for multiple LLM
4//! providers. Credentials are persisted in an encrypted JSON store.
5
6use agentzero_storage::EncryptedJsonStore;
7use anyhow::anyhow;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::time::{SystemTime, UNIX_EPOCH};
11use url::Url;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AuthProfile {
15    pub name: String,
16    pub provider: String,
17    pub token: String,
18    pub created_at_epoch_secs: u64,
19    pub updated_at_epoch_secs: u64,
20    #[serde(default)]
21    pub refresh_token: Option<String>,
22    #[serde(default)]
23    pub token_expires_at_epoch_secs: Option<u64>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27struct AuthState {
28    active_profile: Option<String>,
29    profiles: Vec<AuthProfile>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
33pub struct PendingOAuthLogin {
34    pub provider: String,
35    pub profile: String,
36    pub code_verifier: String,
37    pub state: String,
38    pub created_at_epoch_secs: u64,
39    #[serde(default)]
40    pub redirect_uri: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize)]
44pub struct AuthProfileSummary {
45    pub name: String,
46    pub provider: String,
47    pub active: bool,
48    pub created_at_epoch_secs: u64,
49    pub updated_at_epoch_secs: u64,
50    pub has_refresh_token: bool,
51    pub token_expires_at_epoch_secs: Option<u64>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct AuthStatus {
56    pub active_profile: Option<String>,
57    pub active_provider: Option<String>,
58    pub active_token_expires_at_epoch_secs: Option<u64>,
59    pub active_has_refresh_token: bool,
60    pub total_profiles: usize,
61}
62
63#[derive(Debug, Clone, Copy, Eq, PartialEq)]
64pub enum RefreshStatus {
65    Valid,
66    Refreshed,
67    ExpiredNeedsLogin,
68}
69
70#[derive(Debug, Clone, Eq, PartialEq)]
71pub struct RefreshResult {
72    pub profile: String,
73    pub status: RefreshStatus,
74}
75
76/// The result of credential resolution from auth profiles.
77#[derive(Debug, Clone)]
78pub struct ResolvedCredential {
79    /// The API token / access token.
80    pub token: String,
81    /// The provider kind the token belongs to (e.g. "openai-codex").
82    pub provider: String,
83    /// How the credential was resolved.
84    pub source: CredentialSource,
85}
86
87/// Describes which resolution path produced the credential.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum CredentialSource {
90    /// An explicitly requested profile by name.
91    ExplicitProfile(String),
92    /// The active profile matched the requested provider.
93    ProviderMatch,
94    /// The active profile is for a different provider than requested.
95    /// The caller should update its provider config to match.
96    ActiveProfile(String),
97}
98
99pub struct AuthManager {
100    state_store: EncryptedJsonStore,
101    pending_store: EncryptedJsonStore,
102}
103
104impl AuthManager {
105    pub fn in_config_dir(config_dir: &Path) -> anyhow::Result<Self> {
106        Ok(Self {
107            state_store: EncryptedJsonStore::in_config_dir(config_dir, "auth_profiles.json")?,
108            pending_store: EncryptedJsonStore::in_config_dir(
109                config_dir,
110                "auth_pending_oauth.json",
111            )?,
112        })
113    }
114
115    pub fn login(
116        &self,
117        profile_name: &str,
118        provider: &str,
119        token: &str,
120        activate: bool,
121    ) -> anyhow::Result<()> {
122        self.upsert_token(profile_name, provider, token, None, None, activate)
123    }
124
125    pub fn paste_token(
126        &self,
127        profile_name: &str,
128        provider: &str,
129        token: &str,
130        activate: bool,
131    ) -> anyhow::Result<()> {
132        self.upsert_token(profile_name, provider, token, None, None, activate)
133    }
134
135    pub fn paste_redirect(
136        &self,
137        profile_name: &str,
138        provider: &str,
139        redirect_or_code: &str,
140        activate: bool,
141    ) -> anyhow::Result<()> {
142        let code = extract_oauth_code(redirect_or_code);
143        self.upsert_token(profile_name, provider, &code, None, None, activate)
144    }
145
146    pub fn store_oauth_tokens(
147        &self,
148        profile_name: &str,
149        provider: &str,
150        access_token: &str,
151        refresh_token: Option<&str>,
152        expires_in_secs: Option<u64>,
153        activate: bool,
154    ) -> anyhow::Result<()> {
155        self.upsert_token(
156            profile_name,
157            provider,
158            access_token,
159            refresh_token,
160            expires_in_secs,
161            activate,
162        )
163    }
164
165    pub fn save_pending_oauth_login(&self, pending: &PendingOAuthLogin) -> anyhow::Result<()> {
166        self.pending_store.save(pending)
167    }
168
169    pub fn load_pending_oauth_login(&self) -> anyhow::Result<Option<PendingOAuthLogin>> {
170        self.pending_store.load_optional()
171    }
172
173    pub fn clear_pending_oauth_login(&self) -> anyhow::Result<()> {
174        self.pending_store.delete()
175    }
176
177    pub fn refresh(
178        &self,
179        profile_name: &str,
180        access_token: &str,
181        refresh_token: Option<&str>,
182        expires_in_secs: Option<u64>,
183        activate: bool,
184    ) -> anyhow::Result<()> {
185        if profile_name.trim().is_empty() {
186            return Err(anyhow!("profile name must not be empty"));
187        }
188        if access_token.trim().is_empty() {
189            return Err(anyhow!("access token must not be empty"));
190        }
191
192        let mut state = self.load_state()?;
193        let Some(existing) = state
194            .profiles
195            .iter_mut()
196            .find(|profile| profile.name.eq_ignore_ascii_case(profile_name))
197        else {
198            return Err(anyhow!("profile `{profile_name}` not found"));
199        };
200
201        let now = now_epoch_secs();
202        existing.token = access_token.trim().to_string();
203        if let Some(value) = refresh_token {
204            if !value.trim().is_empty() {
205                existing.refresh_token = Some(value.trim().to_string());
206            }
207        }
208        existing.token_expires_at_epoch_secs =
209            expires_in_secs.map(|ttl| now.saturating_add(ttl.max(1)));
210        existing.updated_at_epoch_secs = now;
211        if activate {
212            state.active_profile = Some(profile_name.trim().to_string());
213        }
214        self.persist_state(&state)
215    }
216
217    pub fn refresh_for_provider(
218        &self,
219        provider: &str,
220        profile_name: Option<&str>,
221    ) -> anyhow::Result<Option<RefreshResult>> {
222        if provider.trim().is_empty() {
223            return Err(anyhow!("provider must not be empty"));
224        }
225
226        let mut state = self.load_state()?;
227        let now = now_epoch_secs();
228        let selected_idx = self.find_refresh_profile_index(&state, provider, profile_name);
229
230        let Some(idx) = selected_idx else {
231            return Ok(None);
232        };
233
234        let selected = &mut state.profiles[idx];
235        let expiry = selected.token_expires_at_epoch_secs;
236        let is_expired = expiry.is_some_and(|value| value <= now.saturating_add(60));
237        if !is_expired {
238            return Ok(Some(RefreshResult {
239                profile: selected.name.clone(),
240                status: RefreshStatus::Valid,
241            }));
242        }
243
244        let has_refresh = selected
245            .refresh_token
246            .as_deref()
247            .is_some_and(|value| !value.trim().is_empty());
248        if !has_refresh {
249            return Ok(Some(RefreshResult {
250                profile: selected.name.clone(),
251                status: RefreshStatus::ExpiredNeedsLogin,
252            }));
253        }
254
255        selected.token_expires_at_epoch_secs = Some(now.saturating_add(3600));
256        selected.updated_at_epoch_secs = now;
257        let profile = selected.name.clone();
258        self.persist_state(&state)?;
259        Ok(Some(RefreshResult {
260            profile,
261            status: RefreshStatus::Refreshed,
262        }))
263    }
264
265    pub fn logout(&self, profile_name: Option<&str>) -> anyhow::Result<bool> {
266        let mut state = self.load_state()?;
267        match profile_name
268            .map(str::trim)
269            .filter(|value| !value.is_empty())
270        {
271            Some(name) => {
272                let before = state.profiles.len();
273                state
274                    .profiles
275                    .retain(|profile| !profile.name.eq_ignore_ascii_case(name));
276                if state
277                    .active_profile
278                    .as_deref()
279                    .is_some_and(|active| active.eq_ignore_ascii_case(name))
280                {
281                    state.active_profile = None;
282                }
283                let changed = before != state.profiles.len();
284                if changed {
285                    self.persist_state(&state)?;
286                }
287                Ok(changed)
288            }
289            None => {
290                let had_active = state.active_profile.take().is_some();
291                if had_active {
292                    self.persist_state(&state)?;
293                }
294                Ok(had_active)
295            }
296        }
297    }
298
299    pub fn remove_profile(&self, provider: &str, profile_name: &str) -> anyhow::Result<bool> {
300        let provider = provider.trim();
301        let profile_name = profile_name.trim();
302        if provider.is_empty() || profile_name.is_empty() {
303            return Ok(false);
304        }
305
306        let mut state = self.load_state()?;
307        let before = state.profiles.len();
308        state.profiles.retain(|profile| {
309            !(profile.provider.eq_ignore_ascii_case(provider)
310                && profile.name.eq_ignore_ascii_case(profile_name))
311        });
312
313        if state
314            .active_profile
315            .as_deref()
316            .is_some_and(|active| active.eq_ignore_ascii_case(profile_name))
317            && !state
318                .profiles
319                .iter()
320                .any(|profile| profile.name.eq_ignore_ascii_case(profile_name))
321        {
322            state.active_profile = None;
323        }
324
325        let changed = before != state.profiles.len();
326        if changed {
327            self.persist_state(&state)?;
328        }
329        Ok(changed)
330    }
331
332    pub fn use_profile(&self, profile_name: &str) -> anyhow::Result<()> {
333        if profile_name.trim().is_empty() {
334            return Err(anyhow!("profile name must not be empty"));
335        }
336
337        let mut state = self.load_state()?;
338        if !state
339            .profiles
340            .iter()
341            .any(|profile| profile.name.eq_ignore_ascii_case(profile_name))
342        {
343            return Err(anyhow!("profile `{profile_name}` not found"));
344        }
345        state.active_profile = Some(profile_name.trim().to_string());
346        self.persist_state(&state)
347    }
348
349    pub fn list_profiles(&self) -> anyhow::Result<Vec<AuthProfileSummary>> {
350        let state = self.load_state()?;
351        let active = state.active_profile.unwrap_or_default();
352        Ok(state
353            .profiles
354            .into_iter()
355            .map(|profile| AuthProfileSummary {
356                active: profile.name.eq_ignore_ascii_case(&active),
357                name: profile.name,
358                provider: profile.provider,
359                created_at_epoch_secs: profile.created_at_epoch_secs,
360                updated_at_epoch_secs: profile.updated_at_epoch_secs,
361                has_refresh_token: profile
362                    .refresh_token
363                    .as_deref()
364                    .map(|value| !value.trim().is_empty())
365                    .unwrap_or(false),
366                token_expires_at_epoch_secs: profile.token_expires_at_epoch_secs,
367            })
368            .collect())
369    }
370
371    /// Return the stored token for the given provider, preferring the active
372    /// profile, then a profile named "default", then any matching profile.
373    pub fn active_token_for_provider(&self, provider: &str) -> anyhow::Result<Option<String>> {
374        let state = self.load_state()?;
375        let idx = self.find_refresh_profile_index(&state, provider, None);
376        Ok(idx.map(|i| state.profiles[i].token.clone()))
377    }
378
379    /// Look up a profile by name (regardless of provider kind).
380    /// Returns `(provider_kind, token)` if found.
381    pub fn token_for_profile(
382        &self,
383        profile_name: &str,
384    ) -> anyhow::Result<Option<(String, String)>> {
385        let state = self.load_state()?;
386        let found = state
387            .profiles
388            .iter()
389            .find(|p| p.name.eq_ignore_ascii_case(profile_name));
390        Ok(found.map(|p| (p.provider.clone(), p.token.clone())))
391    }
392
393    /// Resolve credentials from stored auth profiles.
394    ///
395    /// Resolution order:
396    /// 1. If `profile_name` is `Some`, look up that profile by name.
397    /// 2. Active profile matching `current_provider`.
398    /// 3. Any active profile (provider may differ — caller should update config).
399    ///
400    /// Returns `None` if no usable credential is found.
401    pub fn resolve_credential(
402        &self,
403        profile_name: Option<&str>,
404        current_provider: &str,
405    ) -> anyhow::Result<Option<ResolvedCredential>> {
406        // 1. Explicit profile by name.
407        if let Some(name) = profile_name {
408            let (provider, token) = self.token_for_profile(name)?.ok_or_else(|| {
409                anyhow!(
410                    "auth profile '{name}' not found — run `agentzero auth list` to see available profiles"
411                )
412            })?;
413            anyhow::ensure!(
414                !token.trim().is_empty(),
415                "auth profile '{name}' has an empty token — re-authenticate with `agentzero auth login`"
416            );
417            return Ok(Some(ResolvedCredential {
418                token,
419                provider,
420                source: CredentialSource::ExplicitProfile(name.to_string()),
421            }));
422        }
423
424        // 2. Profile matching current provider.
425        if let Some(token) = self.active_token_for_provider(current_provider)? {
426            if !token.trim().is_empty() {
427                return Ok(Some(ResolvedCredential {
428                    token,
429                    provider: current_provider.to_string(),
430                    source: CredentialSource::ProviderMatch,
431                }));
432            }
433        }
434
435        // 3. Any active profile (may differ from current_provider).
436        let status = self.status()?;
437        if let Some(ref active_name) = status.active_profile {
438            if let Some((provider, token)) = self.token_for_profile(active_name)? {
439                if !token.trim().is_empty() {
440                    return Ok(Some(ResolvedCredential {
441                        token,
442                        provider,
443                        source: CredentialSource::ActiveProfile(active_name.clone()),
444                    }));
445                }
446            }
447        }
448
449        Ok(None)
450    }
451
452    pub fn status(&self) -> anyhow::Result<AuthStatus> {
453        let state = self.load_state()?;
454        let active_profile = state.active_profile.clone();
455        let active = active_profile.as_deref().and_then(|name| {
456            state
457                .profiles
458                .iter()
459                .find(|profile| profile.name.eq_ignore_ascii_case(name))
460        });
461
462        Ok(AuthStatus {
463            active_profile,
464            active_provider: active.map(|profile| profile.provider.clone()),
465            active_token_expires_at_epoch_secs: active
466                .and_then(|profile| profile.token_expires_at_epoch_secs),
467            active_has_refresh_token: active
468                .and_then(|profile| profile.refresh_token.as_deref())
469                .map(|value| !value.trim().is_empty())
470                .unwrap_or(false),
471            total_profiles: state.profiles.len(),
472        })
473    }
474
475    /// Return the health of all profiles (for `auth status` display).
476    pub fn token_health(&self) -> anyhow::Result<Vec<ProfileHealth>> {
477        let state = self.load_state()?;
478        let now = now_epoch_secs();
479        Ok(state
480            .profiles
481            .iter()
482            .map(|profile| ProfileHealth {
483                name: profile.name.clone(),
484                provider: profile.provider.clone(),
485                health: assess_token_health(profile.token_expires_at_epoch_secs, now),
486                has_refresh_token: profile
487                    .refresh_token
488                    .as_deref()
489                    .is_some_and(|v| !v.trim().is_empty()),
490                expires_at_epoch_secs: profile.token_expires_at_epoch_secs,
491            })
492            .collect())
493    }
494
495    /// Check that the active profile for `provider` has a valid token.
496    /// If the token is expired and has a refresh token, attempts a local
497    /// refresh (extends expiry). Returns the token if valid/refreshed, or
498    /// an error if the token is expired and cannot be refreshed.
499    ///
500    /// This is designed to be called by the runtime before each provider call.
501    pub fn ensure_valid_token(&self, provider: &str) -> anyhow::Result<Option<String>> {
502        let result = self.refresh_for_provider(provider, None)?;
503        match result {
504            None => Ok(None),
505            Some(ref r) if r.status == RefreshStatus::Valid => {
506                self.active_token_for_provider(provider)
507            }
508            Some(ref r) if r.status == RefreshStatus::Refreshed => {
509                self.active_token_for_provider(provider)
510            }
511            Some(r) => Err(anyhow!(
512                "auth token for profile '{}' has expired and cannot be auto-refreshed — \
513                 run `agentzero auth login --provider {}`",
514                r.profile,
515                provider
516            )),
517        }
518    }
519
520    fn upsert_token(
521        &self,
522        profile_name: &str,
523        provider: &str,
524        token: &str,
525        refresh_token: Option<&str>,
526        expires_in_secs: Option<u64>,
527        activate: bool,
528    ) -> anyhow::Result<()> {
529        if profile_name.trim().is_empty() {
530            return Err(anyhow!("profile name must not be empty"));
531        }
532        if provider.trim().is_empty() {
533            return Err(anyhow!("provider must not be empty"));
534        }
535        if token.trim().is_empty() {
536            return Err(anyhow!("token must not be empty"));
537        }
538
539        let mut state = self.load_state()?;
540        let now = now_epoch_secs();
541        let expires = expires_in_secs.map(|ttl| now.saturating_add(ttl.max(1)));
542        let refresh = refresh_token.and_then(|value| {
543            let trimmed = value.trim();
544            (!trimmed.is_empty()).then(|| trimmed.to_string())
545        });
546
547        if let Some(existing) = state
548            .profiles
549            .iter_mut()
550            .find(|profile| profile.name.eq_ignore_ascii_case(profile_name))
551        {
552            existing.provider = provider.trim().to_string();
553            existing.token = token.trim().to_string();
554            if let Some(value) = refresh {
555                existing.refresh_token = Some(value);
556            }
557            if expires.is_some() {
558                existing.token_expires_at_epoch_secs = expires;
559            }
560            existing.updated_at_epoch_secs = now;
561        } else {
562            state.profiles.push(AuthProfile {
563                name: profile_name.trim().to_string(),
564                provider: provider.trim().to_string(),
565                token: token.trim().to_string(),
566                created_at_epoch_secs: now,
567                updated_at_epoch_secs: now,
568                refresh_token: refresh,
569                token_expires_at_epoch_secs: expires,
570            });
571        }
572
573        if activate {
574            state.active_profile = Some(profile_name.trim().to_string());
575        }
576
577        self.persist_state(&state)
578    }
579
580    fn load_state(&self) -> anyhow::Result<AuthState> {
581        self.state_store.load_or_default()
582    }
583
584    fn persist_state(&self, state: &AuthState) -> anyhow::Result<()> {
585        self.state_store.save(state)
586    }
587
588    fn find_refresh_profile_index(
589        &self,
590        state: &AuthState,
591        provider: &str,
592        profile_name: Option<&str>,
593    ) -> Option<usize> {
594        if let Some(profile) = profile_name
595            .map(str::trim)
596            .filter(|value| !value.is_empty())
597        {
598            return state.profiles.iter().position(|candidate| {
599                candidate.provider.eq_ignore_ascii_case(provider)
600                    && candidate.name.eq_ignore_ascii_case(profile)
601            });
602        }
603
604        if let Some(active_profile) = state
605            .active_profile
606            .as_deref()
607            .map(str::trim)
608            .filter(|value| !value.is_empty())
609        {
610            if let Some(idx) = state.profiles.iter().position(|candidate| {
611                candidate.provider.eq_ignore_ascii_case(provider)
612                    && candidate.name.eq_ignore_ascii_case(active_profile)
613            }) {
614                return Some(idx);
615            }
616        }
617
618        if let Some(idx) = state.profiles.iter().position(|candidate| {
619            candidate.provider.eq_ignore_ascii_case(provider)
620                && candidate.name.eq_ignore_ascii_case("default")
621        }) {
622            return Some(idx);
623        }
624
625        state
626            .profiles
627            .iter()
628            .position(|candidate| candidate.provider.eq_ignore_ascii_case(provider))
629    }
630}
631
632/// Token health status for display and pre-call validation.
633#[derive(Debug, Clone, Copy, Eq, PartialEq)]
634pub enum TokenHealth {
635    /// Token has no expiry or expiry is more than 5 minutes away.
636    Valid,
637    /// Token expires within 5 minutes (but is not yet expired).
638    ExpiringSoon,
639    /// Token has expired.
640    Expired,
641    /// No token expiry information available (API key flow).
642    NoExpiry,
643}
644
645impl TokenHealth {
646    pub fn label(&self) -> &'static str {
647        match self {
648            TokenHealth::Valid => "valid",
649            TokenHealth::ExpiringSoon => "expiring soon",
650            TokenHealth::Expired => "expired",
651            TokenHealth::NoExpiry => "no expiry",
652        }
653    }
654}
655
656/// Per-profile health report returned by `token_health`.
657#[derive(Debug, Clone)]
658pub struct ProfileHealth {
659    pub name: String,
660    pub provider: String,
661    pub health: TokenHealth,
662    pub has_refresh_token: bool,
663    pub expires_at_epoch_secs: Option<u64>,
664}
665
666fn now_epoch_secs() -> u64 {
667    SystemTime::now()
668        .duration_since(UNIX_EPOCH)
669        .expect("time should be after epoch")
670        .as_secs()
671}
672
673fn assess_token_health(expires_at: Option<u64>, now: u64) -> TokenHealth {
674    match expires_at {
675        None => TokenHealth::NoExpiry,
676        Some(exp) if exp <= now => TokenHealth::Expired,
677        Some(exp) if exp <= now.saturating_add(300) => TokenHealth::ExpiringSoon,
678        Some(_) => TokenHealth::Valid,
679    }
680}
681
682pub fn extract_oauth_code_from_input(redirect_or_code: &str) -> String {
683    extract_oauth_code(redirect_or_code)
684}
685
686fn extract_oauth_code(redirect_or_code: &str) -> String {
687    let raw = redirect_or_code.trim();
688    if let Ok(parsed) = Url::parse(raw) {
689        if let Some((_, value)) = parsed
690            .query_pairs()
691            .find(|(key, _)| key.eq_ignore_ascii_case("code"))
692        {
693            return value.to_string();
694        }
695    }
696    raw.to_string()
697}
698
699pub fn extract_oauth_state(redirect_or_code: &str) -> Option<String> {
700    let raw = redirect_or_code.trim();
701    Url::parse(raw).ok().and_then(|parsed| {
702        parsed
703            .query_pairs()
704            .find(|(key, _)| key.eq_ignore_ascii_case("state"))
705            .map(|(_, value)| value.to_string())
706    })
707}
708
709// ---------------------------------------------------------------------------
710// Gemini OAuth helpers
711// ---------------------------------------------------------------------------
712
713/// Google Gemini OAuth configuration.
714pub struct GeminiOAuthConfig {
715    pub client_id: String,
716    pub client_secret: String,
717    pub redirect_uri: String,
718}
719
720/// Build the Google OAuth2 authorization URL for Gemini API access.
721pub fn gemini_authorize_url(config: &GeminiOAuthConfig, state: &str) -> String {
722    let scope = "https://www.googleapis.com/auth/generative-language";
723    format!(
724        "https://accounts.google.com/o/oauth2/v2/auth?\
725         client_id={client_id}&\
726         redirect_uri={redirect_uri}&\
727         response_type=code&\
728         scope={scope}&\
729         state={state}&\
730         access_type=offline&\
731         prompt=consent",
732        client_id =
733            url::form_urlencoded::byte_serialize(config.client_id.as_bytes()).collect::<String>(),
734        redirect_uri = url::form_urlencoded::byte_serialize(config.redirect_uri.as_bytes())
735            .collect::<String>(),
736        scope = url::form_urlencoded::byte_serialize(scope.as_bytes()).collect::<String>(),
737        state = url::form_urlencoded::byte_serialize(state.as_bytes()).collect::<String>(),
738    )
739}
740
741/// Exchange a Google OAuth authorization code for tokens.
742/// Returns `(access_token, refresh_token, expires_in_secs)`.
743pub async fn gemini_exchange_code(
744    config: &GeminiOAuthConfig,
745    code: &str,
746) -> anyhow::Result<(String, Option<String>, Option<u64>)> {
747    let client = reqwest::Client::new();
748    let response = client
749        .post("https://oauth2.googleapis.com/token")
750        .form(&[
751            ("code", code),
752            ("client_id", &config.client_id),
753            ("client_secret", &config.client_secret),
754            ("redirect_uri", &config.redirect_uri),
755            ("grant_type", "authorization_code"),
756        ])
757        .send()
758        .await?;
759
760    if !response.status().is_success() {
761        let body = response.text().await.unwrap_or_default();
762        anyhow::bail!("Gemini token exchange failed: {body}");
763    }
764
765    let json: serde_json::Value = response.json().await?;
766    let access_token = json["access_token"]
767        .as_str()
768        .ok_or_else(|| anyhow!("missing access_token in Gemini response"))?
769        .to_string();
770    let refresh_token = json["refresh_token"].as_str().map(|s| s.to_string());
771    let expires_in = json["expires_in"].as_u64();
772
773    Ok((access_token, refresh_token, expires_in))
774}
775
776/// Refresh a Google OAuth access token using a refresh token.
777/// Returns `(new_access_token, expires_in_secs)`.
778pub async fn gemini_refresh_token(
779    config: &GeminiOAuthConfig,
780    refresh_token: &str,
781) -> anyhow::Result<(String, Option<u64>)> {
782    let client = reqwest::Client::new();
783    let response = client
784        .post("https://oauth2.googleapis.com/token")
785        .form(&[
786            ("refresh_token", refresh_token),
787            ("client_id", &config.client_id),
788            ("client_secret", &config.client_secret),
789            ("grant_type", "refresh_token"),
790        ])
791        .send()
792        .await?;
793
794    if !response.status().is_success() {
795        let body = response.text().await.unwrap_or_default();
796        anyhow::bail!("Gemini token refresh failed: {body}");
797    }
798
799    let json: serde_json::Value = response.json().await?;
800    let access_token = json["access_token"]
801        .as_str()
802        .ok_or_else(|| anyhow!("missing access_token in refresh response"))?
803        .to_string();
804    let expires_in = json["expires_in"].as_u64();
805
806    Ok((access_token, expires_in))
807}
808
809// ---------------------------------------------------------------------------
810// Token storage migration
811// ---------------------------------------------------------------------------
812
813const AUTH_STATE_VERSION: u32 = 2;
814
815/// Internal versioned wrapper for auth state persistence.
816#[allow(dead_code)]
817#[derive(Debug, Clone, Serialize, Deserialize)]
818struct VersionedAuthState {
819    #[serde(default = "default_version")]
820    version: u32,
821    #[serde(flatten)]
822    state: AuthState,
823}
824
825#[allow(dead_code)]
826fn default_version() -> u32 {
827    1
828}
829
830impl AuthManager {
831    /// Migrate auth state from v1 to v2 format if needed.
832    /// v1 → v2: adds `refresh_token` and `token_expires_at_epoch_secs` fields
833    /// to profiles (handled by serde `#[serde(default)]`). The migration
834    /// just bumps the version marker.
835    pub fn migrate_if_needed(&self) -> anyhow::Result<bool> {
836        let raw: Option<serde_json::Value> = self.state_store.load_optional()?;
837        let Some(mut value) = raw else {
838            return Ok(false);
839        };
840
841        let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
842
843        if version >= AUTH_STATE_VERSION {
844            return Ok(false);
845        }
846
847        // v1 → v2: just stamp the new version. The serde defaults handle
848        // missing fields (refresh_token, token_expires_at_epoch_secs).
849        value["version"] = serde_json::json!(AUTH_STATE_VERSION);
850        self.state_store.save(&value)?;
851        Ok(true)
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::{
858        extract_oauth_state, AuthManager, CredentialSource, PendingOAuthLogin, RefreshStatus,
859    };
860    use std::fs;
861    use std::path::PathBuf;
862    use std::sync::atomic::{AtomicU64, Ordering};
863    use std::time::{SystemTime, UNIX_EPOCH};
864
865    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
866
867    fn temp_dir() -> PathBuf {
868        let nanos = SystemTime::now()
869            .duration_since(UNIX_EPOCH)
870            .expect("time should be after epoch")
871            .as_nanos();
872        let seq = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
873        let dir = std::env::temp_dir().join(format!(
874            "agentzero-auth-{}-{nanos}-{seq}",
875            std::process::id()
876        ));
877        fs::create_dir_all(&dir).expect("temp dir should be created");
878        dir
879    }
880
881    #[test]
882    fn login_and_status_round_trip_success_path() {
883        let dir = temp_dir();
884        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
885        manager
886            .login("default", "openrouter", "tok-test", true)
887            .expect("login should succeed");
888
889        let status = manager.status().expect("status should be readable");
890        assert_eq!(status.active_profile.as_deref(), Some("default"));
891        assert_eq!(status.active_provider.as_deref(), Some("openrouter"));
892        assert_eq!(status.total_profiles, 1);
893
894        fs::remove_dir_all(dir).expect("temp dir should be removed");
895    }
896
897    #[test]
898    fn paste_redirect_extracts_code_success_path() {
899        let dir = temp_dir();
900        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
901        manager
902            .paste_redirect(
903                "oauth",
904                "openai-codex",
905                "https://example.com/callback?code=abc123",
906                true,
907            )
908            .expect("paste redirect should succeed");
909
910        let listed = manager.list_profiles().expect("profiles should load");
911        let profile = listed
912            .iter()
913            .find(|profile| profile.name == "oauth")
914            .expect("oauth profile should exist");
915        assert_eq!(profile.provider, "openai-codex");
916
917        fs::remove_dir_all(dir).expect("temp dir should be removed");
918    }
919
920    #[test]
921    fn refresh_updates_expiry_success_path() {
922        let dir = temp_dir();
923        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
924        manager
925            .login("default", "openai-codex", "tok-old", true)
926            .expect("seed login should succeed");
927        manager
928            .refresh("default", "tok-new", Some("refresh-1"), Some(3600), true)
929            .expect("refresh should succeed");
930
931        let status = manager.status().expect("status should load");
932        assert!(status.active_token_expires_at_epoch_secs.is_some());
933        assert!(status.active_has_refresh_token);
934
935        fs::remove_dir_all(dir).expect("temp dir should be removed");
936    }
937
938    #[test]
939    fn login_rejects_empty_token_negative_path() {
940        let dir = temp_dir();
941        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
942        let err = manager
943            .login("default", "openrouter", "   ", true)
944            .expect_err("empty token should fail");
945        assert!(err.to_string().contains("token must not be empty"));
946
947        fs::remove_dir_all(dir).expect("temp dir should be removed");
948    }
949
950    #[test]
951    fn use_profile_fails_when_profile_missing_negative_path() {
952        let dir = temp_dir();
953        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
954        let err = manager
955            .use_profile("missing")
956            .expect_err("missing profile should fail");
957        assert!(err.to_string().contains("not found"));
958
959        fs::remove_dir_all(dir).expect("temp dir should be removed");
960    }
961
962    #[test]
963    fn refresh_fails_when_profile_missing_negative_path() {
964        let dir = temp_dir();
965        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
966        let err = manager
967            .refresh("missing", "tok", None, Some(10), true)
968            .expect_err("refresh on missing profile should fail");
969        assert!(err.to_string().contains("not found"));
970
971        fs::remove_dir_all(dir).expect("temp dir should be removed");
972    }
973
974    #[test]
975    fn refresh_for_provider_uses_default_profile_success_path() {
976        let dir = temp_dir();
977        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
978        manager
979            .login("default", "openai-codex", "tok", true)
980            .expect("seed login should succeed");
981
982        let result = manager
983            .refresh_for_provider("openai-codex", None)
984            .expect("refresh should succeed")
985            .expect("profile should be found");
986        assert_eq!(result.profile, "default");
987        assert_eq!(result.status, RefreshStatus::Valid);
988
989        fs::remove_dir_all(dir).expect("temp dir should be removed");
990    }
991
992    #[test]
993    fn refresh_for_provider_reports_missing_provider_profile_negative_path() {
994        let dir = temp_dir();
995        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
996        manager
997            .login("default", "openrouter", "tok", true)
998            .expect("seed login should succeed");
999
1000        let result = manager
1001            .refresh_for_provider("gemini", None)
1002            .expect("lookup should succeed");
1003        assert!(result.is_none());
1004
1005        fs::remove_dir_all(dir).expect("temp dir should be removed");
1006    }
1007
1008    #[test]
1009    fn remove_profile_removes_provider_profile_pair_success_path() {
1010        let dir = temp_dir();
1011        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1012        manager
1013            .login("default", "openai-codex", "tok", true)
1014            .expect("seed login should succeed");
1015        manager
1016            .login("backup", "anthropic", "tok2", false)
1017            .expect("seed second profile should succeed");
1018
1019        let removed = manager
1020            .remove_profile("openai-codex", "default")
1021            .expect("remove should succeed");
1022        assert!(removed);
1023
1024        let listed = manager.list_profiles().expect("profiles should load");
1025        assert_eq!(listed.len(), 1);
1026        assert_eq!(listed[0].provider, "anthropic");
1027        assert_eq!(listed[0].name, "backup");
1028
1029        fs::remove_dir_all(dir).expect("temp dir should be removed");
1030    }
1031
1032    #[test]
1033    fn remove_profile_returns_false_when_missing_negative_path() {
1034        let dir = temp_dir();
1035        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1036        manager
1037            .login("default", "openai-codex", "tok", true)
1038            .expect("seed login should succeed");
1039
1040        let removed = manager
1041            .remove_profile("gemini", "default")
1042            .expect("remove should succeed");
1043        assert!(!removed);
1044
1045        fs::remove_dir_all(dir).expect("temp dir should be removed");
1046    }
1047
1048    #[test]
1049    fn pending_oauth_round_trip_success_path() {
1050        let dir = temp_dir();
1051        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1052        let pending = PendingOAuthLogin {
1053            provider: "openai-codex".to_string(),
1054            profile: "default".to_string(),
1055            code_verifier: "v1".to_string(),
1056            state: "s1".to_string(),
1057            created_at_epoch_secs: 1,
1058            redirect_uri: Some("http://localhost:1455/auth/callback".to_string()),
1059        };
1060        manager
1061            .save_pending_oauth_login(&pending)
1062            .expect("save pending oauth should succeed");
1063        let loaded = manager
1064            .load_pending_oauth_login()
1065            .expect("load pending oauth should succeed")
1066            .expect("pending oauth should exist");
1067        assert_eq!(loaded, pending);
1068        manager
1069            .clear_pending_oauth_login()
1070            .expect("clear pending oauth should succeed");
1071        assert!(manager
1072            .load_pending_oauth_login()
1073            .expect("load after clear should succeed")
1074            .is_none());
1075
1076        fs::remove_dir_all(dir).expect("temp dir should be removed");
1077    }
1078
1079    #[test]
1080    fn extract_oauth_state_returns_none_without_state_negative_path() {
1081        assert_eq!(
1082            extract_oauth_state("https://example.test/callback?code=abc"),
1083            None
1084        );
1085    }
1086
1087    #[test]
1088    fn resolve_credential_explicit_profile_success_path() {
1089        let dir = temp_dir();
1090        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1091        manager
1092            .login("default", "openai-codex", "tok-explicit", true)
1093            .expect("login should succeed");
1094
1095        let cred = manager
1096            .resolve_credential(Some("default"), "openrouter")
1097            .expect("resolve should succeed")
1098            .expect("credential should be found");
1099        assert_eq!(cred.token, "tok-explicit");
1100        assert_eq!(cred.provider, "openai-codex");
1101        assert_eq!(
1102            cred.source,
1103            CredentialSource::ExplicitProfile("default".to_string())
1104        );
1105
1106        fs::remove_dir_all(dir).expect("temp dir should be removed");
1107    }
1108
1109    #[test]
1110    fn resolve_credential_provider_match_success_path() {
1111        let dir = temp_dir();
1112        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1113        manager
1114            .login("default", "openrouter", "tok-match", true)
1115            .expect("login should succeed");
1116
1117        let cred = manager
1118            .resolve_credential(None, "openrouter")
1119            .expect("resolve should succeed")
1120            .expect("credential should be found");
1121        assert_eq!(cred.token, "tok-match");
1122        assert_eq!(cred.provider, "openrouter");
1123        assert_eq!(cred.source, CredentialSource::ProviderMatch);
1124
1125        fs::remove_dir_all(dir).expect("temp dir should be removed");
1126    }
1127
1128    #[test]
1129    fn resolve_credential_active_profile_fallback_success_path() {
1130        let dir = temp_dir();
1131        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1132        manager
1133            .login("default", "openai-codex", "tok-fallback", true)
1134            .expect("login should succeed");
1135
1136        // Config says "openrouter" but active profile is "openai-codex".
1137        let cred = manager
1138            .resolve_credential(None, "openrouter")
1139            .expect("resolve should succeed")
1140            .expect("credential should be found");
1141        assert_eq!(cred.token, "tok-fallback");
1142        assert_eq!(cred.provider, "openai-codex");
1143        assert_eq!(
1144            cred.source,
1145            CredentialSource::ActiveProfile("default".to_string())
1146        );
1147
1148        fs::remove_dir_all(dir).expect("temp dir should be removed");
1149    }
1150
1151    #[test]
1152    fn resolve_credential_returns_none_when_empty() {
1153        let dir = temp_dir();
1154        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1155
1156        let result = manager
1157            .resolve_credential(None, "openrouter")
1158            .expect("resolve should succeed");
1159        assert!(result.is_none());
1160
1161        fs::remove_dir_all(dir).expect("temp dir should be removed");
1162    }
1163
1164    #[test]
1165    fn resolve_credential_explicit_missing_profile_fails() {
1166        let dir = temp_dir();
1167        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1168
1169        let err = manager
1170            .resolve_credential(Some("nonexistent"), "openrouter")
1171            .expect_err("missing profile should fail");
1172        assert!(err.to_string().contains("not found"));
1173
1174        fs::remove_dir_all(dir).expect("temp dir should be removed");
1175    }
1176
1177    // --- Token health tests ---
1178
1179    #[test]
1180    fn assess_token_health_valid_when_no_expiry() {
1181        assert_eq!(
1182            super::assess_token_health(None, 1000),
1183            super::TokenHealth::NoExpiry
1184        );
1185    }
1186
1187    #[test]
1188    fn assess_token_health_valid_when_far_future() {
1189        assert_eq!(
1190            super::assess_token_health(Some(2000), 1000),
1191            super::TokenHealth::Valid
1192        );
1193    }
1194
1195    #[test]
1196    fn assess_token_health_expiring_soon_within_5_minutes() {
1197        // 200 seconds from now is within 300 seconds (5 min) threshold.
1198        assert_eq!(
1199            super::assess_token_health(Some(1200), 1000),
1200            super::TokenHealth::ExpiringSoon
1201        );
1202    }
1203
1204    #[test]
1205    fn assess_token_health_expired_when_past() {
1206        assert_eq!(
1207            super::assess_token_health(Some(999), 1000),
1208            super::TokenHealth::Expired
1209        );
1210    }
1211
1212    #[test]
1213    fn token_health_returns_health_for_all_profiles() {
1214        let dir = temp_dir();
1215        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1216
1217        // Profile with no expiry (API key flow).
1218        manager
1219            .login("key-profile", "anthropic", "sk-ant-test", true)
1220            .expect("login should succeed");
1221
1222        // Profile with future expiry (OAuth flow).
1223        manager
1224            .store_oauth_tokens(
1225                "oauth-profile",
1226                "openai-codex",
1227                "access-tok",
1228                Some("refresh-tok"),
1229                Some(7200),
1230                false,
1231            )
1232            .expect("store oauth tokens should succeed");
1233
1234        let health = manager.token_health().expect("health should succeed");
1235        assert_eq!(health.len(), 2);
1236
1237        let key_health = health
1238            .iter()
1239            .find(|h| h.name == "key-profile")
1240            .expect("key profile should be in health");
1241        assert_eq!(key_health.health, super::TokenHealth::NoExpiry);
1242        assert!(!key_health.has_refresh_token);
1243
1244        let oauth_health = health
1245            .iter()
1246            .find(|h| h.name == "oauth-profile")
1247            .expect("oauth profile should be in health");
1248        assert_eq!(oauth_health.health, super::TokenHealth::Valid);
1249        assert!(oauth_health.has_refresh_token);
1250
1251        fs::remove_dir_all(dir).expect("temp dir should be removed");
1252    }
1253
1254    #[test]
1255    fn ensure_valid_token_returns_none_when_no_profile() {
1256        let dir = temp_dir();
1257        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1258
1259        let result = manager
1260            .ensure_valid_token("openrouter")
1261            .expect("ensure should succeed");
1262        assert!(result.is_none());
1263
1264        fs::remove_dir_all(dir).expect("temp dir should be removed");
1265    }
1266
1267    #[test]
1268    fn ensure_valid_token_returns_token_when_valid() {
1269        let dir = temp_dir();
1270        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1271        manager
1272            .login("default", "openrouter", "sk-valid", true)
1273            .expect("login should succeed");
1274
1275        let token = manager
1276            .ensure_valid_token("openrouter")
1277            .expect("ensure should succeed")
1278            .expect("token should be returned");
1279        assert_eq!(token, "sk-valid");
1280
1281        fs::remove_dir_all(dir).expect("temp dir should be removed");
1282    }
1283
1284    // --- Gemini OAuth ---
1285
1286    #[test]
1287    fn gemini_authorize_url_contains_required_params() {
1288        let config = super::GeminiOAuthConfig {
1289            client_id: "test-client-id".to_string(),
1290            client_secret: "secret".to_string(),
1291            redirect_uri: "http://localhost:8080/callback".to_string(),
1292        };
1293        let url = super::gemini_authorize_url(&config, "test-state-123");
1294        assert!(url.contains("client_id=test-client-id"));
1295        assert!(url.contains("state=test-state-123"));
1296        assert!(url.contains("access_type=offline"));
1297        assert!(url.contains("generative-language"));
1298    }
1299
1300    // --- Token storage migration ---
1301
1302    #[test]
1303    fn migrate_if_needed_returns_false_on_empty_store() {
1304        let dir = temp_dir();
1305        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1306        let migrated = manager.migrate_if_needed().expect("migrate should succeed");
1307        assert!(!migrated);
1308        fs::remove_dir_all(dir).expect("temp dir should be removed");
1309    }
1310
1311    #[test]
1312    fn migrate_if_needed_returns_false_when_already_current() {
1313        let dir = temp_dir();
1314        let manager = AuthManager::in_config_dir(&dir).expect("manager should construct");
1315        manager
1316            .login("default", "openai", "tok-1", true)
1317            .expect("login should succeed");
1318        // First migration stamps v2.
1319        let _ = manager.migrate_if_needed();
1320        // Second call should return false.
1321        let migrated = manager.migrate_if_needed().expect("migrate should succeed");
1322        assert!(!migrated);
1323        fs::remove_dir_all(dir).expect("temp dir should be removed");
1324    }
1325}