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 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 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 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 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#[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 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
430pub 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
618pub struct PendingOAuthLogin {
619 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
735pub struct AuthFlowContext<'a> {
747 pub config: &'a Config,
748 pub auth_service: &'a AuthService,
749 pub client: &'a reqwest::Client,
750}
751
752pub enum RefreshStatus {
756 Refreshed { profile: String },
759 NoProfile,
762}
763
764#[async_trait::async_trait]
765pub trait AuthProviderFlow: Send + Sync {
766 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 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 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 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
826pub 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
1010pub struct GeminiFlow;
1013
1014impl GeminiFlow {
1015 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
1283pub 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}