Skip to main content

zeroclaw_providers/auth/
mod.rs

1pub mod anthropic_token;
2pub mod gemini_oauth;
3pub mod oauth_common;
4pub mod openai_oauth;
5pub mod profiles;
6
7use crate::auth::openai_oauth::refresh_access_token;
8use crate::auth::profiles::{
9    AuthProfile, AuthProfileKind, AuthProfilesData, AuthProfilesStore, TokenSet, profile_id,
10};
11use anyhow::Result;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, OnceLock};
15use std::time::{Duration, Instant};
16use zeroclaw_config::schema::Config;
17
18const OPENAI_CODEX_PROVIDER: &str = "openai-codex";
19const ANTHROPIC_PROVIDER: &str = "anthropic";
20const GEMINI_PROVIDER: &str = "gemini";
21const DEFAULT_PROFILE_NAME: &str = "default";
22const OPENAI_REFRESH_SKEW_SECS: u64 = 90;
23const OPENAI_REFRESH_FAILURE_BACKOFF_SECS: u64 = 10;
24const OAUTH_REFRESH_MAX_ATTEMPTS: usize = 3;
25const OAUTH_REFRESH_RETRY_BASE_DELAY_MS: u64 = 350;
26static REFRESH_BACKOFFS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
27
28#[derive(Clone)]
29pub struct AuthService {
30    store: AuthProfilesStore,
31    client: reqwest::Client,
32}
33
34impl AuthService {
35    pub fn from_config(config: &Config) -> Self {
36        let state_dir = state_dir_from_config(config);
37        Self::new(&state_dir, config.secrets.encrypt)
38    }
39
40    pub fn new(state_dir: &Path, encrypt_secrets: bool) -> Self {
41        Self {
42            store: AuthProfilesStore::new(state_dir, encrypt_secrets),
43            client: reqwest::Client::new(),
44        }
45    }
46
47    pub async fn load_profiles(&self) -> Result<AuthProfilesData> {
48        self.store.load().await
49    }
50
51    pub async fn store_openai_tokens(
52        &self,
53        profile_name: &str,
54        token_set: crate::auth::profiles::TokenSet,
55        account_id: Option<String>,
56        set_active: bool,
57    ) -> Result<AuthProfile> {
58        let mut profile = AuthProfile::new_oauth(OPENAI_CODEX_PROVIDER, profile_name, token_set);
59        profile.account_id = account_id;
60        self.store
61            .upsert_profile(profile.clone(), set_active)
62            .await?;
63        Ok(profile)
64    }
65
66    pub async fn store_gemini_tokens(
67        &self,
68        profile_name: &str,
69        token_set: crate::auth::profiles::TokenSet,
70        account_id: Option<String>,
71        set_active: bool,
72    ) -> Result<AuthProfile> {
73        let mut profile = AuthProfile::new_oauth(GEMINI_PROVIDER, profile_name, token_set);
74        profile.account_id = account_id;
75        self.store
76            .upsert_profile(profile.clone(), set_active)
77            .await?;
78        Ok(profile)
79    }
80
81    pub async fn store_model_provider_token(
82        &self,
83        model_provider: &str,
84        profile_name: &str,
85        token: &str,
86        metadata: HashMap<String, String>,
87        set_active: bool,
88    ) -> Result<AuthProfile> {
89        let mut profile = AuthProfile::new_token(model_provider, profile_name, token.to_string());
90        profile.metadata.extend(metadata);
91        self.store
92            .upsert_profile(profile.clone(), set_active)
93            .await?;
94        Ok(profile)
95    }
96
97    pub async fn set_active_profile(
98        &self,
99        model_provider: &str,
100        requested_profile: &str,
101    ) -> Result<String> {
102        let model_provider = normalize_model_provider(model_provider)?;
103        let data = self.store.load().await?;
104        let profile_id = resolve_requested_profile_id(&model_provider, requested_profile);
105
106        let profile = data.profiles.get(&profile_id).ok_or_else(|| {
107            ::zeroclaw_log::record!(
108                WARN,
109                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
110                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
111                    .with_attrs(::serde_json::json!({
112                        "profile_id": &profile_id,
113                        "reason": "auth_profile_not_found",
114                    })),
115                "auth: profile not found"
116            );
117            anyhow::Error::msg(format!("Auth profile not found: {profile_id}"))
118        })?;
119
120        if profile.model_provider != model_provider {
121            anyhow::bail!(
122                "Profile {profile_id} belongs to model_provider {}, not {}",
123                profile.model_provider,
124                model_provider
125            );
126        }
127
128        self.store
129            .set_active_profile(&model_provider, &profile_id)
130            .await?;
131        Ok(profile_id)
132    }
133
134    pub async fn remove_profile(
135        &self,
136        model_provider: &str,
137        requested_profile: &str,
138    ) -> Result<bool> {
139        let model_provider = normalize_model_provider(model_provider)?;
140        let profile_id = resolve_requested_profile_id(&model_provider, requested_profile);
141        self.store.remove_profile(&profile_id).await
142    }
143
144    pub async fn get_profile(
145        &self,
146        model_provider: &str,
147        profile_override: Option<&str>,
148    ) -> Result<Option<AuthProfile>> {
149        let model_provider = normalize_model_provider(model_provider)?;
150        let data = self.store.load().await?;
151        let Some(profile_id) = select_profile_id(&data, &model_provider, profile_override) else {
152            return Ok(None);
153        };
154        Ok(data.profiles.get(&profile_id).cloned())
155    }
156
157    pub async fn get_provider_bearer_token(
158        &self,
159        model_provider: &str,
160        profile_override: Option<&str>,
161    ) -> Result<Option<String>> {
162        let profile = self.get_profile(model_provider, profile_override).await?;
163        let Some(profile) = profile else {
164            return Ok(None);
165        };
166
167        let credential = match profile.kind {
168            AuthProfileKind::Token => profile.token,
169            AuthProfileKind::OAuth => profile.token_set.map(|t| t.access_token),
170        };
171
172        Ok(credential.filter(|t| !t.trim().is_empty()))
173    }
174
175    pub async fn get_valid_openai_access_token(
176        &self,
177        profile_override: Option<&str>,
178    ) -> Result<Option<String>> {
179        let data = self.store.load().await?;
180        let Some(profile_id) = select_profile_id(&data, OPENAI_CODEX_PROVIDER, profile_override)
181        else {
182            return Ok(None);
183        };
184
185        let Some(profile) = data.profiles.get(&profile_id) else {
186            return Ok(None);
187        };
188
189        let Some(token_set) = profile.token_set.as_ref() else {
190            anyhow::bail!("OpenAI Codex auth profile is not OAuth-based: {profile_id}");
191        };
192
193        if !token_set.is_expiring_within(Duration::from_secs(OPENAI_REFRESH_SKEW_SECS)) {
194            return Ok(Some(token_set.access_token.clone()));
195        }
196
197        let Some(refresh_token) = token_set.refresh_token.clone() else {
198            return Ok(Some(token_set.access_token.clone()));
199        };
200
201        let refresh_lock = refresh_lock_for_profile(&profile_id);
202        let _guard = refresh_lock.lock().await;
203
204        // Re-load after waiting for lock to avoid duplicate refreshes.
205        let data = self.store.load().await?;
206        let Some(latest_profile) = data.profiles.get(&profile_id) else {
207            return Ok(None);
208        };
209
210        let Some(latest_tokens) = latest_profile.token_set.as_ref() else {
211            anyhow::bail!("OpenAI Codex auth profile is missing token set: {profile_id}");
212        };
213
214        if !latest_tokens.is_expiring_within(Duration::from_secs(OPENAI_REFRESH_SKEW_SECS)) {
215            return Ok(Some(latest_tokens.access_token.clone()));
216        }
217
218        let refresh_token = latest_tokens.refresh_token.clone().unwrap_or(refresh_token);
219
220        if let Some(remaining) = refresh_backoff_remaining(&profile_id) {
221            anyhow::bail!(
222                "OpenAI token refresh is in backoff for {remaining}s due to previous failures"
223            );
224        }
225
226        let mut refreshed =
227            match refresh_openai_access_token_with_retries(&self.client, &refresh_token).await {
228                Ok(tokens) => {
229                    clear_refresh_backoff(&profile_id);
230                    tokens
231                }
232                Err(err) => {
233                    set_refresh_backoff(
234                        &profile_id,
235                        Duration::from_secs(OPENAI_REFRESH_FAILURE_BACKOFF_SECS),
236                    );
237                    return Err(err);
238                }
239            };
240        if refreshed.refresh_token.is_none() {
241            refreshed
242                .refresh_token
243                .clone_from(&latest_tokens.refresh_token);
244        }
245
246        let account_id = openai_oauth::extract_account_id_from_jwt(&refreshed.access_token)
247            .or_else(|| latest_profile.account_id.clone());
248
249        let updated = self
250            .store
251            .update_profile(&profile_id, |profile| {
252                profile.kind = AuthProfileKind::OAuth;
253                profile.token_set = Some(refreshed.clone());
254                profile.account_id.clone_from(&account_id);
255                Ok(())
256            })
257            .await?;
258
259        Ok(updated.token_set.map(|t| t.access_token))
260    }
261
262    /// Get a valid Gemini OAuth access token, refreshing if necessary.
263    ///
264    /// `client_id` and `client_secret` are the OAuth app credentials from
265    /// the per-alias `[model_providers.gemini.<alias>]` typed config —
266    /// required when a refresh is triggered. Required when the cached
267    /// access token is near expiry; ignored when the access token is
268    /// still valid. Pass empty strings only if the caller is certain
269    /// the token won't need refresh in this call.
270    ///
271    /// Returns `None` if no Gemini profile exists.
272    pub async fn get_valid_gemini_access_token(
273        &self,
274        profile_override: Option<&str>,
275        client_id: &str,
276        client_secret: &str,
277    ) -> Result<Option<String>> {
278        let data = self.store.load().await?;
279        let Some(profile_id) = select_profile_id(&data, GEMINI_PROVIDER, profile_override) else {
280            return Ok(None);
281        };
282
283        let Some(profile) = data.profiles.get(&profile_id) else {
284            return Ok(None);
285        };
286
287        let Some(token_set) = profile.token_set.as_ref() else {
288            anyhow::bail!("Gemini auth profile is not OAuth-based: {profile_id}");
289        };
290
291        if !token_set.is_expiring_within(Duration::from_secs(OPENAI_REFRESH_SKEW_SECS)) {
292            return Ok(Some(token_set.access_token.clone()));
293        }
294
295        let Some(refresh_token) = token_set.refresh_token.clone() else {
296            return Ok(Some(token_set.access_token.clone()));
297        };
298
299        let refresh_lock = refresh_lock_for_profile(&profile_id);
300        let _guard = refresh_lock.lock().await;
301
302        // Re-load after waiting for lock to avoid duplicate refreshes.
303        let data = self.store.load().await?;
304        let Some(latest_profile) = data.profiles.get(&profile_id) else {
305            return Ok(None);
306        };
307
308        let Some(latest_tokens) = latest_profile.token_set.as_ref() else {
309            anyhow::bail!("Gemini auth profile is missing token set: {profile_id}");
310        };
311
312        if !latest_tokens.is_expiring_within(Duration::from_secs(OPENAI_REFRESH_SKEW_SECS)) {
313            return Ok(Some(latest_tokens.access_token.clone()));
314        }
315
316        let refresh_token = latest_tokens.refresh_token.clone().unwrap_or(refresh_token);
317
318        if let Some(remaining) = refresh_backoff_remaining(&profile_id) {
319            anyhow::bail!(
320                "Gemini token refresh is in backoff for {remaining}s due to previous failures"
321            );
322        }
323
324        let mut refreshed = match refresh_gemini_access_token_with_retries(
325            &self.client,
326            client_id,
327            client_secret,
328            &refresh_token,
329        )
330        .await
331        {
332            Ok(tokens) => {
333                clear_refresh_backoff(&profile_id);
334                tokens
335            }
336            Err(err) => {
337                set_refresh_backoff(
338                    &profile_id,
339                    Duration::from_secs(OPENAI_REFRESH_FAILURE_BACKOFF_SECS),
340                );
341                return Err(err);
342            }
343        };
344        if refreshed.refresh_token.is_none() {
345            refreshed
346                .refresh_token
347                .clone_from(&latest_tokens.refresh_token);
348        }
349
350        let account_id = refreshed
351            .id_token
352            .as_deref()
353            .and_then(gemini_oauth::extract_account_email_from_id_token)
354            .or_else(|| latest_profile.account_id.clone());
355
356        let updated = self
357            .store
358            .update_profile(&profile_id, |profile| {
359                profile.kind = AuthProfileKind::OAuth;
360                profile.token_set = Some(refreshed.clone());
361                profile.account_id.clone_from(&account_id);
362                Ok(())
363            })
364            .await?;
365
366        Ok(updated.token_set.map(|t| t.access_token))
367    }
368
369    /// Get Gemini profile info (for model_provider initialization).
370    pub async fn get_gemini_profile(
371        &self,
372        profile_override: Option<&str>,
373    ) -> Result<Option<AuthProfile>> {
374        self.get_profile(GEMINI_PROVIDER, profile_override).await
375    }
376}
377
378/// Auth-flow provider — the finite set the `auth login` /
379/// `auth paste-redirect` / `auth status` commands dispatch on. Synonym
380/// collapse and canonical-name rendering are both serde-driven via the
381/// `rename_all` + `alias` attributes, so no string-literal pattern match
382/// is needed at the parsing boundary or any dispatch site.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
384#[serde(rename_all = "kebab-case")]
385pub enum AuthProvider {
386    #[serde(alias = "openai_codex", alias = "codex")]
387    OpenaiCodex,
388    #[serde(alias = "claude")]
389    Anthropic,
390    #[serde(alias = "google", alias = "vertex")]
391    Gemini,
392}
393
394impl std::str::FromStr for AuthProvider {
395    type Err = anyhow::Error;
396
397    fn from_str(raw: &str) -> Result<Self> {
398        let normalized = raw.trim().to_ascii_lowercase();
399        if normalized.is_empty() {
400            anyhow::bail!("ModelProvider name cannot be empty");
401        }
402        serde_json::from_value(serde_json::Value::String(normalized.clone())).map_err(|_| {
403            ::zeroclaw_log::record!(
404                WARN,
405                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
406                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
407                    .with_attrs(::serde_json::json!({"normalized": &normalized})),
408                "auth: unknown auth provider"
409            );
410            anyhow::Error::msg(format!(
411                "Unknown auth provider `{normalized}`. Supported: openai-codex, anthropic, gemini.",
412            ))
413        })
414    }
415}
416
417impl AuthProvider {
418    /// Canonical lowercase name for storage, profile lookup, and on-the-wire
419    /// references. Each arm is enum-variant dispatch — adding a variant
420    /// requires updating this match (compile-time enforced).
421    pub fn as_canonical(&self) -> &'static str {
422        match self {
423            Self::OpenaiCodex => OPENAI_CODEX_PROVIDER,
424            Self::Anthropic => ANTHROPIC_PROVIDER,
425            Self::Gemini => GEMINI_PROVIDER,
426        }
427    }
428}
429
430/// Permissive string-returning normalizer for token-storage callers
431/// (paste-token, setup-token, set-active-profile, …) that accept
432/// arbitrary provider names. Known OAuth-flow providers collapse to
433/// their canonical form via [`AuthProvider`]; unknown names lower-case
434/// and pass through unchanged so storage works for any bearer-token
435/// provider operators want to support. Empty input is rejected.
436///
437/// OAuth-dispatch sites (`auth login` / `auth refresh`) parse via
438/// [`AuthProvider`] directly — that path is strict by design.
439pub fn normalize_model_provider(model_provider: &str) -> Result<String> {
440    if let Ok(provider) = model_provider.parse::<AuthProvider>() {
441        return Ok(provider.as_canonical().to_string());
442    }
443    let normalized = model_provider.trim().to_ascii_lowercase();
444    if normalized.is_empty() {
445        anyhow::bail!("ModelProvider name cannot be empty");
446    }
447    Ok(normalized)
448}
449
450pub fn state_dir_from_config(config: &Config) -> PathBuf {
451    config
452        .config_path
453        .parent()
454        .map_or_else(|| PathBuf::from("."), PathBuf::from)
455}
456
457pub fn default_profile_id(model_provider: &str) -> String {
458    profile_id(model_provider, DEFAULT_PROFILE_NAME)
459}
460
461fn resolve_requested_profile_id(model_provider: &str, requested: &str) -> String {
462    if requested.contains(':') {
463        requested.to_string()
464    } else {
465        profile_id(model_provider, requested)
466    }
467}
468
469pub fn select_profile_id(
470    data: &AuthProfilesData,
471    model_provider: &str,
472    profile_override: Option<&str>,
473) -> Option<String> {
474    if let Some(override_profile) = profile_override {
475        let requested = resolve_requested_profile_id(model_provider, override_profile);
476        if data.profiles.contains_key(&requested) {
477            return Some(requested);
478        }
479        return None;
480    }
481
482    if let Some(active) = data.active_profiles.get(model_provider)
483        && data.profiles.contains_key(active)
484    {
485        return Some(active.clone());
486    }
487
488    let default = default_profile_id(model_provider);
489    if data.profiles.contains_key(&default) {
490        return Some(default);
491    }
492
493    data.profiles
494        .iter()
495        .find_map(|(id, profile)| (profile.model_provider == model_provider).then(|| id.clone()))
496}
497
498async fn refresh_openai_access_token_with_retries(
499    client: &reqwest::Client,
500    refresh_token: &str,
501) -> Result<TokenSet> {
502    let mut last_error: Option<anyhow::Error> = None;
503
504    for attempt in 1..=OAUTH_REFRESH_MAX_ATTEMPTS {
505        match refresh_access_token(client, refresh_token).await {
506            Ok(tokens) => return Ok(tokens),
507            Err(err) => {
508                let should_retry = attempt < OAUTH_REFRESH_MAX_ATTEMPTS;
509                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"attempt": attempt, "max_attempts": OAUTH_REFRESH_MAX_ATTEMPTS, "retry": should_retry, "error": format!("{}", err)})), "OpenAI token refresh failed");
510                last_error = Some(err);
511                if should_retry {
512                    tokio::time::sleep(Duration::from_millis(
513                        OAUTH_REFRESH_RETRY_BASE_DELAY_MS * attempt as u64,
514                    ))
515                    .await;
516                }
517            }
518        }
519    }
520
521    Err(last_error.unwrap_or_else(|| {
522        ::zeroclaw_log::record!(
523            ERROR,
524            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
525                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
526                .with_attrs(::serde_json::json!({"oauth_provider": "openai"})),
527            "auth: OpenAI token refresh exhausted retries"
528        );
529        anyhow::Error::msg("OpenAI token refresh failed")
530    }))
531}
532
533async fn refresh_gemini_access_token_with_retries(
534    client: &reqwest::Client,
535    client_id: &str,
536    client_secret: &str,
537    refresh_token: &str,
538) -> Result<TokenSet> {
539    let mut last_error: Option<anyhow::Error> = None;
540
541    for attempt in 1..=OAUTH_REFRESH_MAX_ATTEMPTS {
542        match gemini_oauth::refresh_access_token(client, client_id, client_secret, refresh_token)
543            .await
544        {
545            Ok(tokens) => return Ok(tokens),
546            Err(err) => {
547                let should_retry = attempt < OAUTH_REFRESH_MAX_ATTEMPTS;
548                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"attempt": attempt, "max_attempts": OAUTH_REFRESH_MAX_ATTEMPTS, "retry": should_retry, "error": format!("{}", err)})), "Gemini token refresh failed");
549                last_error = Some(err);
550                if should_retry {
551                    tokio::time::sleep(Duration::from_millis(
552                        OAUTH_REFRESH_RETRY_BASE_DELAY_MS * attempt as u64,
553                    ))
554                    .await;
555                }
556            }
557        }
558    }
559
560    Err(last_error.unwrap_or_else(|| {
561        ::zeroclaw_log::record!(
562            ERROR,
563            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
564                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
565                .with_attrs(::serde_json::json!({"oauth_provider": "gemini"})),
566            "auth: Gemini token refresh exhausted retries"
567        );
568        anyhow::Error::msg("Gemini token refresh failed")
569    }))
570}
571
572fn refresh_lock_for_profile(profile_id: &str) -> Arc<tokio::sync::Mutex<()>> {
573    static LOCKS: OnceLock<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> = OnceLock::new();
574
575    let table = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
576    let mut guard = table.lock().expect("refresh lock table poisoned");
577
578    guard
579        .entry(profile_id.to_string())
580        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
581        .clone()
582}
583
584fn refresh_backoff_remaining(profile_id: &str) -> Option<u64> {
585    let map = REFRESH_BACKOFFS.get_or_init(|| Mutex::new(HashMap::new()));
586    let mut guard = map.lock().ok()?;
587    let now = Instant::now();
588    let deadline = guard.get(profile_id).copied()?;
589    if deadline <= now {
590        guard.remove(profile_id);
591        return None;
592    }
593    Some((deadline - now).as_secs().max(1))
594}
595
596fn set_refresh_backoff(profile_id: &str, duration: Duration) {
597    let map = REFRESH_BACKOFFS.get_or_init(|| Mutex::new(HashMap::new()));
598    if let Ok(mut guard) = map.lock() {
599        guard.insert(profile_id.to_string(), Instant::now() + duration);
600    }
601}
602
603fn clear_refresh_backoff(profile_id: &str) {
604    let map = REFRESH_BACKOFFS.get_or_init(|| Mutex::new(HashMap::new()));
605    if let Ok(mut guard) = map.lock() {
606        guard.remove(profile_id);
607    }
608}
609
610// ════════════════════════════════════════════════════════════════════════
611// PendingOAuthLogin — encrypted on-disk state for browser/paste-redirect
612// fallback. Moved here from `src/main.rs` so the AuthProviderFlow trait
613// impls below can save/load/clear without crossing the bin/lib boundary.
614// ════════════════════════════════════════════════════════════════════════
615
616/// Generic pending OAuth login state, shared across model providers.
617#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
618pub struct PendingOAuthLogin {
619    /// Canonical model-provider name as stored on disk. Kept as `String`
620    /// for serialization compatibility with already-saved files written
621    /// before the [`AuthProvider`] enum existed.
622    pub model_provider: String,
623    pub profile: String,
624    pub code_verifier: String,
625    pub state: String,
626    pub created_at: String,
627}
628
629#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
630struct PendingOAuthLoginFile {
631    #[serde(default)]
632    model_provider: Option<String>,
633    profile: String,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    code_verifier: Option<String>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    encrypted_code_verifier: Option<String>,
638    state: String,
639    created_at: String,
640}
641
642fn pending_oauth_login_path(config: &Config, model_provider: &str) -> PathBuf {
643    let filename = format!("auth-{}-pending.json", model_provider);
644    state_dir_from_config(config).join(filename)
645}
646
647fn pending_oauth_secret_store(config: &Config) -> zeroclaw_config::secrets::SecretStore {
648    zeroclaw_config::secrets::SecretStore::new(
649        &state_dir_from_config(config),
650        config.secrets.encrypt,
651    )
652}
653
654#[cfg(unix)]
655fn set_owner_only_permissions(path: &std::path::Path) -> Result<()> {
656    use std::os::unix::fs::PermissionsExt;
657    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
658    Ok(())
659}
660
661#[cfg(not(unix))]
662fn set_owner_only_permissions(_path: &std::path::Path) -> Result<()> {
663    Ok(())
664}
665
666pub fn save_pending_oauth_login(config: &Config, pending: &PendingOAuthLogin) -> Result<()> {
667    let path = pending_oauth_login_path(config, &pending.model_provider);
668    if let Some(parent) = path.parent() {
669        std::fs::create_dir_all(parent)?;
670    }
671    let secret_store = pending_oauth_secret_store(config);
672    let encrypted_code_verifier = secret_store.encrypt(&pending.code_verifier)?;
673    let persisted = PendingOAuthLoginFile {
674        model_provider: Some(pending.model_provider.clone()),
675        profile: pending.profile.clone(),
676        code_verifier: None,
677        encrypted_code_verifier: Some(encrypted_code_verifier),
678        state: pending.state.clone(),
679        created_at: pending.created_at.clone(),
680    };
681    let tmp = path.with_extension(format!(
682        "tmp.{}.{}",
683        std::process::id(),
684        chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
685    ));
686    let json = serde_json::to_vec_pretty(&persisted)?;
687    std::fs::write(&tmp, json)?;
688    set_owner_only_permissions(&tmp)?;
689    std::fs::rename(tmp, &path)?;
690    set_owner_only_permissions(&path)?;
691    Ok(())
692}
693
694pub fn load_pending_oauth_login(
695    config: &Config,
696    model_provider: &str,
697) -> Result<Option<PendingOAuthLogin>> {
698    let path = pending_oauth_login_path(config, model_provider);
699    if !path.exists() {
700        return Ok(None);
701    }
702    let bytes = std::fs::read(&path)?;
703    if bytes.is_empty() {
704        return Ok(None);
705    }
706    let persisted: PendingOAuthLoginFile = serde_json::from_slice(&bytes)?;
707    let secret_store = pending_oauth_secret_store(config);
708    let code_verifier = if let Some(encrypted) = persisted.encrypted_code_verifier {
709        secret_store.decrypt(&encrypted)?
710    } else if let Some(plaintext) = persisted.code_verifier {
711        plaintext
712    } else {
713        anyhow::bail!("Pending {} login is missing code verifier", model_provider);
714    };
715    Ok(Some(PendingOAuthLogin {
716        model_provider: persisted
717            .model_provider
718            .unwrap_or_else(|| model_provider.to_string()),
719        profile: persisted.profile,
720        code_verifier,
721        state: persisted.state,
722        created_at: persisted.created_at,
723    }))
724}
725
726pub fn clear_pending_oauth_login(config: &Config, model_provider: &str) {
727    let path = pending_oauth_login_path(config, model_provider);
728    if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&path) {
729        let _ = file.set_len(0);
730        let _ = file.sync_all();
731    }
732    let _ = std::fs::remove_file(path);
733}
734
735// ════════════════════════════════════════════════════════════════════════
736// AuthProviderFlow — per-provider auth flow trait, dispatched via
737// `AuthProvider::flow()`. Replaces the string-keyed `match
738// model_provider.as_str() { ... }` blocks formerly in `src/main.rs` —
739// every dispatch now goes through enum-variant matching followed by
740// trait-object virtual call.
741// ════════════════════════════════════════════════════════════════════════
742
743/// Shared context for auth-flow trait methods. Carries the runtime
744/// dependencies each flow needs (config for OAuth client creds, auth
745/// service for token storage, http client for OAuth round-trips).
746pub struct AuthFlowContext<'a> {
747    pub config: &'a Config,
748    pub auth_service: &'a AuthService,
749    pub client: &'a reqwest::Client,
750}
751
752/// Result of [`AuthProviderFlow::refresh_status`] — caller renders the
753/// outcome (CLI message, gateway JSON, etc.) without doing its own
754/// provider-aware formatting.
755pub enum RefreshStatus {
756    /// Token was valid or successfully refreshed; `profile` is the active
757    /// profile name (caller-friendly for printing).
758    Refreshed { profile: String },
759    /// No auth profile exists for this provider; caller decides whether
760    /// to surface a hint to run `auth login`.
761    NoProfile,
762}
763
764#[async_trait::async_trait]
765pub trait AuthProviderFlow: Send + Sync {
766    /// Run the OAuth login flow. The default impl bails — only providers
767    /// with an OAuth login flow override. `import` is a path to an
768    /// existing token-set JSON file for providers that support importing
769    /// already-issued credentials (OpenAI Codex `~/.codex/auth.json`).
770    async fn login(
771        &self,
772        _ctx: &AuthFlowContext<'_>,
773        _profile: &str,
774        _device_code: bool,
775        _import: Option<&std::path::Path>,
776    ) -> Result<()> {
777        anyhow::bail!(
778            "`auth login` is not supported for this provider. Use `auth paste-token` or \
779             `auth setup-token` for bearer-token providers.",
780        )
781    }
782
783    /// Resume an OAuth login from a paste-redirect URL/code. The default
784    /// impl bails for providers that don't expose a browser flow.
785    async fn paste_redirect(
786        &self,
787        _ctx: &AuthFlowContext<'_>,
788        _profile: &str,
789        _input: Option<&str>,
790    ) -> Result<()> {
791        anyhow::bail!(
792            "`auth paste-redirect` is not supported for this provider. Only OpenAI Codex and \
793             Gemini expose a browser-based OAuth flow.",
794        )
795    }
796
797    /// Refresh the access token for `profile_override` (or active
798    /// profile) and report status. Default impl bails for providers
799    /// without a refresh flow.
800    async fn refresh_status(
801        &self,
802        _ctx: &AuthFlowContext<'_>,
803        _profile_override: Option<&str>,
804    ) -> Result<RefreshStatus> {
805        anyhow::bail!(
806            "`auth refresh` is not supported for this provider. Only OpenAI Codex and Gemini \
807             have an in-process token-refresh flow.",
808        )
809    }
810}
811
812impl AuthProvider {
813    /// Resolve the per-variant `AuthProviderFlow` impl for trait dispatch.
814    /// The `match self` here is on enum variants — the only place an
815    /// auth-flow dispatch exists, every other call site routes through
816    /// the returned trait object.
817    pub fn flow(&self) -> Box<dyn AuthProviderFlow> {
818        match self {
819            Self::OpenaiCodex => Box::new(OpenaiCodexFlow),
820            Self::Gemini => Box::new(GeminiFlow),
821            Self::Anthropic => Box::new(AnthropicFlow),
822        }
823    }
824}
825
826// ── OpenAI Codex impl ──────────────────────────────────────────────────
827
828pub struct OpenaiCodexFlow;
829
830#[async_trait::async_trait]
831impl AuthProviderFlow for OpenaiCodexFlow {
832    async fn login(
833        &self,
834        ctx: &AuthFlowContext<'_>,
835        profile: &str,
836        device_code: bool,
837        import: Option<&std::path::Path>,
838    ) -> Result<()> {
839        if let Some(import_path) = import {
840            crate::auth::openai_oauth::import_codex_auth_profile(
841                ctx.auth_service,
842                profile,
843                import_path,
844            )
845            .await?;
846            println!(
847                "Imported auth profile from {}",
848                import_path.display().to_string()
849            );
850            println!("Active profile for openai-codex: {profile}");
851            return Ok(());
852        }
853
854        if device_code {
855            match crate::auth::openai_oauth::start_device_code_flow(ctx.client).await {
856                Ok(device) => {
857                    println!("OpenAI device-code login started.");
858                    println!("Visit: {}", device.verification_uri);
859                    println!("Code:  {}", device.user_code);
860                    if let Some(uri_complete) = &device.verification_uri_complete {
861                        println!("Fast link: {uri_complete}");
862                    }
863                    if let Some(message) = &device.message {
864                        println!("{message}");
865                    }
866                    let token_set =
867                        crate::auth::openai_oauth::poll_device_code_tokens(ctx.client, &device)
868                            .await?;
869                    let account_id = crate::auth::openai_oauth::extract_account_id_from_jwt(
870                        &token_set.access_token,
871                    );
872                    ctx.auth_service
873                        .store_openai_tokens(profile, token_set, account_id, true)
874                        .await?;
875                    println!("Saved profile {profile}");
876                    println!("Active profile for openai-codex: {profile}");
877                    return Ok(());
878                }
879                Err(e) => {
880                    println!("Device-code flow unavailable: {e}. Falling back to browser flow.");
881                }
882            }
883        }
884
885        let pkce = crate::auth::openai_oauth::generate_pkce_state();
886        let authorize_url = crate::auth::openai_oauth::build_authorize_url(&pkce);
887
888        let pending = PendingOAuthLogin {
889            model_provider: "openai".into(),
890            profile: profile.to_string(),
891            code_verifier: pkce.code_verifier.clone(),
892            state: pkce.state.clone(),
893            created_at: chrono::Utc::now().to_rfc3339(),
894        };
895        save_pending_oauth_login(ctx.config, &pending)?;
896
897        println!("Open this URL in your browser and authorize access:");
898        println!("{authorize_url}");
899        println!();
900
901        let code = match crate::auth::openai_oauth::receive_loopback_code(
902            &pkce.state,
903            std::time::Duration::from_secs(180),
904        )
905        .await
906        {
907            Ok(code) => {
908                clear_pending_oauth_login(ctx.config, "openai");
909                code
910            }
911            Err(e) => {
912                println!("Callback capture failed: {e}");
913                println!(
914                    "Run `zeroclaw auth paste-redirect --model-provider openai-codex --profile {profile}`"
915                );
916                return Ok(());
917            }
918        };
919
920        let token_set =
921            crate::auth::openai_oauth::exchange_code_for_tokens(ctx.client, &code, &pkce).await?;
922        let account_id =
923            crate::auth::openai_oauth::extract_account_id_from_jwt(&token_set.access_token);
924        ctx.auth_service
925            .store_openai_tokens(profile, token_set, account_id, true)
926            .await?;
927        println!("Saved profile {profile}");
928        println!("Active profile for openai-codex: {profile}");
929        Ok(())
930    }
931
932    async fn paste_redirect(
933        &self,
934        ctx: &AuthFlowContext<'_>,
935        profile: &str,
936        input: Option<&str>,
937    ) -> Result<()> {
938        let pending = load_pending_oauth_login(ctx.config, "openai")?.ok_or_else(|| {
939            ::zeroclaw_log::record!(
940                WARN,
941                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
942                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
943                    .with_attrs(::serde_json::json!({
944                        "oauth_provider": "openai",
945                        "profile": profile,
946                    })),
947                "auth: no pending OpenAI login"
948            );
949            anyhow::Error::msg(
950                "No pending OpenAI login found. Run `zeroclaw auth login --model-provider openai-codex` first.",
951            )
952        })?;
953        if pending.profile != profile {
954            anyhow::bail!(
955                "Pending login profile mismatch: pending={}, requested={}",
956                pending.profile,
957                profile,
958            );
959        }
960        let redirect_input = input.ok_or_else(|| {
961            ::zeroclaw_log::record!(
962                WARN,
963                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
964                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
965                    .with_attrs(::serde_json::json!({"oauth_provider": "openai"})),
966                "auth: paste-redirect requires URL or code"
967            );
968            anyhow::Error::msg("paste-redirect requires the redirect URL or OAuth code")
969        })?;
970        let code = crate::auth::openai_oauth::parse_code_from_redirect(
971            redirect_input,
972            Some(&pending.state),
973        )?;
974        let pkce = crate::auth::openai_oauth::PkceState {
975            code_verifier: pending.code_verifier.clone(),
976            code_challenge: String::new(),
977            state: pending.state.clone(),
978        };
979        let token_set =
980            crate::auth::openai_oauth::exchange_code_for_tokens(ctx.client, &code, &pkce).await?;
981        let account_id =
982            crate::auth::openai_oauth::extract_account_id_from_jwt(&token_set.access_token);
983        ctx.auth_service
984            .store_openai_tokens(profile, token_set, account_id, true)
985            .await?;
986        clear_pending_oauth_login(ctx.config, "openai");
987        println!("Saved profile {profile}");
988        println!("Active profile for openai-codex: {profile}");
989        Ok(())
990    }
991
992    async fn refresh_status(
993        &self,
994        ctx: &AuthFlowContext<'_>,
995        profile_override: Option<&str>,
996    ) -> Result<RefreshStatus> {
997        match ctx
998            .auth_service
999            .get_valid_openai_access_token(profile_override)
1000            .await?
1001        {
1002            Some(_) => Ok(RefreshStatus::Refreshed {
1003                profile: profile_override.unwrap_or("default").to_string(),
1004            }),
1005            None => Ok(RefreshStatus::NoProfile),
1006        }
1007    }
1008}
1009
1010// ── Gemini impl ────────────────────────────────────────────────────────
1011
1012pub struct GeminiFlow;
1013
1014impl GeminiFlow {
1015    /// Look up the per-alias OAuth client credentials. The auth profile
1016    /// name doubles as the Gemini family alias key
1017    /// (`[model_providers.gemini.<profile>]`); the alias config carries
1018    /// the operator's Google Cloud OAuth app credentials.
1019    fn alias_creds<'a>(config: &'a Config, profile: &str) -> Result<(&'a str, &'a str)> {
1020        let alias_cfg = config.providers.models.gemini.get(profile).ok_or_else(|| {
1021            ::zeroclaw_log::record!(
1022                ERROR,
1023                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1024                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1025                    .with_attrs(::serde_json::json!({
1026                        "oauth_provider": "gemini",
1027                        "profile": profile,
1028                        "missing": "alias_cfg",
1029                    })),
1030                "auth: gemini OAuth missing alias config"
1031            );
1032            anyhow::Error::msg(format!(
1033                "Gemini OAuth requires `[model_providers.gemini.{profile}]` to exist with \
1034                 `oauth_client_id` and `oauth_client_secret` set. Register a Google Cloud \
1035                 OAuth app and configure the credentials before running this auth flow.",
1036            ))
1037        })?;
1038        let client_id = alias_cfg
1039            .oauth_client_id
1040            .as_deref()
1041            .map(str::trim)
1042            .filter(|s| !s.is_empty())
1043            .ok_or_else(|| {
1044                ::zeroclaw_log::record!(
1045                    ERROR,
1046                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1047                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1048                        .with_attrs(::serde_json::json!({
1049                            "oauth_provider": "gemini",
1050                            "profile": profile,
1051                            "missing": "oauth_client_id",
1052                        })),
1053                    "auth: gemini OAuth missing oauth_client_id"
1054                );
1055                anyhow::Error::msg(format!(
1056                    "Gemini OAuth requires `oauth_client_id` on `[model_providers.gemini.{profile}]`.",
1057                ))
1058            })?;
1059        let client_secret = alias_cfg
1060            .oauth_client_secret
1061            .as_deref()
1062            .map(str::trim)
1063            .filter(|s| !s.is_empty())
1064            .ok_or_else(|| {
1065                ::zeroclaw_log::record!(
1066                    ERROR,
1067                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1068                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1069                        .with_attrs(::serde_json::json!({
1070                            "oauth_provider": "gemini",
1071                            "profile": profile,
1072                            "missing": "oauth_client_secret",
1073                        })),
1074                    "auth: gemini OAuth missing oauth_client_secret"
1075                );
1076                anyhow::Error::msg(format!(
1077                    "Gemini OAuth requires `oauth_client_secret` on `[model_providers.gemini.{profile}]`.",
1078                ))
1079            })?;
1080        Ok((client_id, client_secret))
1081    }
1082}
1083
1084#[async_trait::async_trait]
1085impl AuthProviderFlow for GeminiFlow {
1086    async fn login(
1087        &self,
1088        ctx: &AuthFlowContext<'_>,
1089        profile: &str,
1090        device_code: bool,
1091        import: Option<&std::path::Path>,
1092    ) -> Result<()> {
1093        if import.is_some() {
1094            anyhow::bail!(
1095                "`auth login --import` currently supports only --model-provider openai-codex.",
1096            );
1097        }
1098        let (client_id, client_secret) = Self::alias_creds(ctx.config, profile)?;
1099
1100        if device_code {
1101            match crate::auth::gemini_oauth::start_device_code_flow(ctx.client, client_id).await {
1102                Ok(device) => {
1103                    println!("Google/Gemini device-code login started.");
1104                    println!("Visit: {}", device.verification_uri);
1105                    println!("Code:  {}", device.user_code);
1106                    if let Some(uri_complete) = &device.verification_uri_complete {
1107                        println!("Fast link: {uri_complete}");
1108                    }
1109                    let token_set = crate::auth::gemini_oauth::poll_device_code_tokens(
1110                        ctx.client,
1111                        client_id,
1112                        client_secret,
1113                        &device,
1114                    )
1115                    .await?;
1116                    let account_id = token_set
1117                        .id_token
1118                        .as_deref()
1119                        .and_then(crate::auth::gemini_oauth::extract_account_email_from_id_token);
1120                    ctx.auth_service
1121                        .store_gemini_tokens(profile, token_set, account_id, true)
1122                        .await?;
1123                    println!("Saved profile {profile}");
1124                    println!("Active profile for gemini: {profile}");
1125                    return Ok(());
1126                }
1127                Err(e) => {
1128                    println!("Device-code flow unavailable: {e}. Falling back to browser flow.");
1129                }
1130            }
1131        }
1132
1133        let pkce = crate::auth::gemini_oauth::generate_pkce_state();
1134        let authorize_url = crate::auth::gemini_oauth::build_authorize_url(client_id, &pkce)?;
1135
1136        let pending = PendingOAuthLogin {
1137            model_provider: "gemini".into(),
1138            profile: profile.to_string(),
1139            code_verifier: pkce.code_verifier.clone(),
1140            state: pkce.state.clone(),
1141            created_at: chrono::Utc::now().to_rfc3339(),
1142        };
1143        save_pending_oauth_login(ctx.config, &pending)?;
1144
1145        println!("Open this URL in your browser and authorize access:");
1146        println!("{authorize_url}");
1147        println!();
1148
1149        let code = match crate::auth::gemini_oauth::receive_loopback_code(
1150            &pkce.state,
1151            std::time::Duration::from_secs(180),
1152        )
1153        .await
1154        {
1155            Ok(code) => {
1156                clear_pending_oauth_login(ctx.config, "gemini");
1157                code
1158            }
1159            Err(e) => {
1160                println!("Callback capture failed: {e}");
1161                println!(
1162                    "Run `zeroclaw auth paste-redirect --model-provider gemini --profile {profile}`",
1163                );
1164                return Ok(());
1165            }
1166        };
1167
1168        let token_set = crate::auth::gemini_oauth::exchange_code_for_tokens(
1169            ctx.client,
1170            client_id,
1171            client_secret,
1172            &code,
1173            &pkce,
1174        )
1175        .await?;
1176        let account_id = token_set
1177            .id_token
1178            .as_deref()
1179            .and_then(crate::auth::gemini_oauth::extract_account_email_from_id_token);
1180        ctx.auth_service
1181            .store_gemini_tokens(profile, token_set, account_id, true)
1182            .await?;
1183        println!("Saved profile {profile}");
1184        println!("Active profile for gemini: {profile}");
1185        Ok(())
1186    }
1187
1188    async fn paste_redirect(
1189        &self,
1190        ctx: &AuthFlowContext<'_>,
1191        profile: &str,
1192        input: Option<&str>,
1193    ) -> Result<()> {
1194        let (client_id, client_secret) = Self::alias_creds(ctx.config, profile)?;
1195        let pending = load_pending_oauth_login(ctx.config, "gemini")?.ok_or_else(|| {
1196            ::zeroclaw_log::record!(
1197                WARN,
1198                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1199                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1200                    .with_attrs(::serde_json::json!({
1201                        "oauth_provider": "gemini",
1202                        "profile": profile,
1203                    })),
1204                "auth: no pending Gemini login"
1205            );
1206            anyhow::Error::msg(
1207                "No pending Gemini login found. Run `zeroclaw auth login --model-provider gemini` first.",
1208            )
1209        })?;
1210        if pending.profile != profile {
1211            anyhow::bail!(
1212                "Pending login profile mismatch: pending={}, requested={}",
1213                pending.profile,
1214                profile,
1215            );
1216        }
1217        let redirect_input = input.ok_or_else(|| {
1218            ::zeroclaw_log::record!(
1219                WARN,
1220                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1221                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1222                    .with_attrs(::serde_json::json!({"oauth_provider": "gemini"})),
1223                "auth: paste-redirect requires URL or code"
1224            );
1225            anyhow::Error::msg("paste-redirect requires the redirect URL or OAuth code")
1226        })?;
1227        let code = crate::auth::gemini_oauth::parse_code_from_redirect(
1228            redirect_input,
1229            Some(&pending.state),
1230        )?;
1231        let pkce = crate::auth::gemini_oauth::PkceState {
1232            code_verifier: pending.code_verifier.clone(),
1233            code_challenge: String::new(),
1234            state: pending.state.clone(),
1235        };
1236        let token_set = crate::auth::gemini_oauth::exchange_code_for_tokens(
1237            ctx.client,
1238            client_id,
1239            client_secret,
1240            &code,
1241            &pkce,
1242        )
1243        .await?;
1244        let account_id = token_set
1245            .id_token
1246            .as_deref()
1247            .and_then(crate::auth::gemini_oauth::extract_account_email_from_id_token);
1248        ctx.auth_service
1249            .store_gemini_tokens(profile, token_set, account_id, true)
1250            .await?;
1251        clear_pending_oauth_login(ctx.config, "gemini");
1252        println!("Saved profile {profile}");
1253        println!("Active profile for gemini: {profile}");
1254        Ok(())
1255    }
1256
1257    async fn refresh_status(
1258        &self,
1259        ctx: &AuthFlowContext<'_>,
1260        profile_override: Option<&str>,
1261    ) -> Result<RefreshStatus> {
1262        let alias_name = profile_override.unwrap_or("default");
1263        let alias_cfg = ctx.config.providers.models.gemini.get(alias_name);
1264        let client_id = alias_cfg
1265            .and_then(|c| c.oauth_client_id.as_deref())
1266            .unwrap_or("");
1267        let client_secret = alias_cfg
1268            .and_then(|c| c.oauth_client_secret.as_deref())
1269            .unwrap_or("");
1270        match ctx
1271            .auth_service
1272            .get_valid_gemini_access_token(profile_override, client_id, client_secret)
1273            .await?
1274        {
1275            Some(_) => Ok(RefreshStatus::Refreshed {
1276                profile: alias_name.to_string(),
1277            }),
1278            None => Ok(RefreshStatus::NoProfile),
1279        }
1280    }
1281}
1282
1283// ── Anthropic impl ─────────────────────────────────────────────────────
1284//
1285// Anthropic auth is bearer-token only (long-lived subscription tokens
1286// from claude.ai). All three OAuth-flow methods rely on the trait's
1287// default `bail!()` impls — Anthropic operators use `auth paste-token`
1288// or `auth setup-token` instead.
1289
1290pub struct AnthropicFlow;
1291
1292impl AuthProviderFlow for AnthropicFlow {}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297    use crate::auth::profiles::{AuthProfile, AuthProfileKind};
1298
1299    #[test]
1300    fn normalize_provider_aliases() {
1301        assert_eq!(normalize_model_provider("codex").unwrap(), "openai-codex");
1302        assert_eq!(normalize_model_provider("claude").unwrap(), "anthropic");
1303        assert_eq!(normalize_model_provider("openai").unwrap(), "openai");
1304    }
1305
1306    #[test]
1307    fn select_profile_prefers_override_then_active_then_default() {
1308        let mut data = AuthProfilesData::default();
1309        let id_active = profile_id("openai-codex", "work");
1310        let id_default = profile_id("openai-codex", "default");
1311
1312        data.profiles.insert(
1313            id_default.clone(),
1314            AuthProfile {
1315                id: id_default.clone(),
1316                model_provider: "openai-codex".into(),
1317                profile_name: "default".into(),
1318                kind: AuthProfileKind::Token,
1319                account_id: None,
1320                workspace_id: None,
1321                token_set: None,
1322                token: Some("x".into()),
1323                metadata: std::collections::BTreeMap::default(),
1324                created_at: chrono::Utc::now(),
1325                updated_at: chrono::Utc::now(),
1326            },
1327        );
1328        data.profiles.insert(
1329            id_active.clone(),
1330            AuthProfile {
1331                id: id_active.clone(),
1332                model_provider: "openai-codex".into(),
1333                profile_name: "work".into(),
1334                kind: AuthProfileKind::Token,
1335                account_id: None,
1336                workspace_id: None,
1337                token_set: None,
1338                token: Some("y".into()),
1339                metadata: std::collections::BTreeMap::default(),
1340                created_at: chrono::Utc::now(),
1341                updated_at: chrono::Utc::now(),
1342            },
1343        );
1344
1345        data.active_profiles
1346            .insert("openai-codex".into(), id_active.clone());
1347
1348        assert_eq!(
1349            select_profile_id(&data, "openai-codex", Some("default")),
1350            Some(id_default)
1351        );
1352        assert_eq!(
1353            select_profile_id(&data, "openai-codex", None),
1354            Some(id_active)
1355        );
1356    }
1357}