1use 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#[derive(Debug, Clone)]
78pub struct ResolvedCredential {
79 pub token: String,
81 pub provider: String,
83 pub source: CredentialSource,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum CredentialSource {
90 ExplicitProfile(String),
92 ProviderMatch,
94 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 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 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 pub fn resolve_credential(
402 &self,
403 profile_name: Option<&str>,
404 current_provider: &str,
405 ) -> anyhow::Result<Option<ResolvedCredential>> {
406 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 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 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 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 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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
634pub enum TokenHealth {
635 Valid,
637 ExpiringSoon,
639 Expired,
641 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#[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
709pub struct GeminiOAuthConfig {
715 pub client_id: String,
716 pub client_secret: String,
717 pub redirect_uri: String,
718}
719
720pub 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
741pub 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
776pub 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
809const AUTH_STATE_VERSION: u32 = 2;
814
815#[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 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 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 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 #[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 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 manager
1219 .login("key-profile", "anthropic", "sk-ant-test", true)
1220 .expect("login should succeed");
1221
1222 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 #[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 #[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 let _ = manager.migrate_if_needed();
1320 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}