1use anyhow::{Context, Result, anyhow, bail};
25use async_trait::async_trait;
26use base64::{Engine, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD};
27use fs2::FileExt;
28use reqwest::Client;
29use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
30use ring::rand::{SecureRandom, SystemRandom};
31use serde::{Deserialize, Serialize};
32use std::fmt;
33use std::fs;
34use std::fs::OpenOptions;
35use std::path::PathBuf;
36use std::sync::{Arc, Mutex};
37use tokio::sync::Mutex as AsyncMutex;
38
39use crate::storage_paths::{auth_storage_dir, write_private_file};
40use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
41
42pub use super::credentials::AuthCredentialsStoreMode;
43use super::credentials::keyring;
44use super::pkce::PkceChallenge;
45
46const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
47const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
48const DEFAULT_OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
64const DEFAULT_OPENAI_ORIGINATOR: &str = "codex_cli_rs";
70const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
74const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
75const OPENAI_STORAGE_SERVICE: &str = "vtcode";
76const OPENAI_STORAGE_USER: &str = "openai_chatgpt_session";
77const OPENAI_SESSION_FILE: &str = "openai_chatgpt.json";
78const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
79const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
80const REFRESH_SKEW_SECS: u64 = 60;
81
82#[derive(Debug)]
94struct OAuthClientIdentity {
95 client_id: String,
96 originator: String,
97}
98
99fn resolve_oauth_client_identity() -> Result<OAuthClientIdentity> {
117 let custom_client_id = std::env::var("VTCODE_OPENAI_OAUTH_CLIENT_ID")
118 .ok()
119 .filter(|v| !v.trim().is_empty());
120 let custom_originator = std::env::var("VTCODE_OPENAI_OAUTH_ORIGINATOR")
121 .ok()
122 .filter(|v| !v.trim().is_empty());
123
124 match (custom_client_id, custom_originator) {
125 (Some(id), Some(originator)) => Ok(OAuthClientIdentity { client_id: id, originator }),
126 (Some(_), None) => bail!(
127 "VTCODE_OPENAI_OAUTH_CLIENT_ID is set but VTCODE_OPENAI_OAUTH_ORIGINATOR is not. \
128 The client ID and originator must be overridden together to form a coherent OAuth \
129 identity. Set VTCODE_OPENAI_OAUTH_ORIGINATOR to match your custom client ID, \
130 or unset VTCODE_OPENAI_OAUTH_CLIENT_ID to use the default Codex identity."
131 ),
132 (None, Some(_)) => bail!(
133 "VTCODE_OPENAI_OAUTH_ORIGINATOR is set but VTCODE_OPENAI_OAUTH_CLIENT_ID is not. \
134 The client ID and originator must be overridden together to form a coherent OAuth \
135 identity. Set VTCODE_OPENAI_OAUTH_CLIENT_ID to match your custom originator, \
136 or unset VTCODE_OPENAI_OAUTH_ORIGINATOR to use the default Codex identity."
137 ),
138 (None, None) => Ok(OAuthClientIdentity {
139 client_id: DEFAULT_OPENAI_CLIENT_ID.to_string(),
140 originator: DEFAULT_OPENAI_ORIGINATOR.to_string(),
141 }),
142 }
143}
144
145#[derive(Clone, Serialize, Deserialize)]
150pub struct OpenAIChatGptSession {
151 pub openai_api_key: String,
154 pub id_token: String,
156 pub access_token: String,
158 pub refresh_token: String,
160 pub account_id: Option<String>,
162 pub email: Option<String>,
164 pub plan: Option<String>,
166 pub obtained_at: u64,
168 pub refreshed_at: u64,
170 pub expires_at: Option<u64>,
172}
173
174impl fmt::Debug for OpenAIChatGptSession {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 f.debug_struct("OpenAIChatGptSession")
177 .field("openai_api_key", &"<redacted>")
178 .field("id_token", &"<redacted>")
179 .field("access_token", &"<redacted>")
180 .field("refresh_token", &"<redacted>")
181 .field("account_id", &self.account_id)
182 .field("email", &self.email)
183 .field("plan", &self.plan)
184 .field("obtained_at", &self.obtained_at)
185 .field("refreshed_at", &self.refreshed_at)
186 .field("expires_at", &self.expires_at)
187 .finish()
188 }
189}
190
191impl OpenAIChatGptSession {
192 fn is_refresh_due(&self) -> bool {
193 let now = now_secs();
194 if let Some(expires_at) = self.expires_at
195 && now.saturating_add(REFRESH_SKEW_SECS) >= expires_at
196 {
197 return true;
198 }
199 now.saturating_sub(self.refreshed_at) >= REFRESH_INTERVAL_SECS
200 }
201}
202
203#[async_trait]
205pub trait OpenAIChatGptSessionRefresher: Send + Sync {
206 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession>;
207}
208
209#[derive(Clone)]
210enum OpenAIChatGptAuthRefreshStrategy {
211 Stored {
212 storage_mode: AuthCredentialsStoreMode,
213 },
214 External {
215 refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
216 },
217}
218
219impl fmt::Debug for OpenAIChatGptAuthRefreshStrategy {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 match self {
222 Self::Stored { storage_mode } => f.debug_struct("Stored").field("storage_mode", storage_mode).finish(),
223 Self::External { .. } => f.debug_struct("External").finish_non_exhaustive(),
224 }
225 }
226}
227
228#[derive(Clone)]
230pub struct OpenAIChatGptAuthHandle {
231 session: Arc<Mutex<OpenAIChatGptSession>>,
232 refresh_gate: Arc<AsyncMutex<()>>,
233 auto_refresh: bool,
234 refresh_strategy: OpenAIChatGptAuthRefreshStrategy,
235}
236
237impl fmt::Debug for OpenAIChatGptAuthHandle {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 f.debug_struct("OpenAIChatGptAuthHandle")
240 .field("auto_refresh", &self.auto_refresh)
241 .field("refresh_strategy", &self.refresh_strategy)
242 .finish()
243 }
244}
245
246impl OpenAIChatGptAuthHandle {
247 pub fn new(
248 session: OpenAIChatGptSession,
249 auth_config: OpenAIAuthConfig,
250 storage_mode: AuthCredentialsStoreMode,
251 ) -> Self {
252 Self {
253 session: Arc::new(Mutex::new(session)),
254 refresh_gate: Arc::new(AsyncMutex::new(())),
255 auto_refresh: auth_config.auto_refresh,
256 refresh_strategy: OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode },
257 }
258 }
259
260 pub fn new_external(
261 session: OpenAIChatGptSession,
262 auto_refresh: bool,
263 refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
264 ) -> Self {
265 Self {
266 session: Arc::new(Mutex::new(session)),
267 refresh_gate: Arc::new(AsyncMutex::new(())),
268 auto_refresh,
269 refresh_strategy: OpenAIChatGptAuthRefreshStrategy::External { refresher },
270 }
271 }
272
273 pub fn snapshot(&self) -> Result<OpenAIChatGptSession> {
274 self.session
275 .lock()
276 .map(|guard| guard.clone())
277 .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))
278 }
279
280 pub fn current_api_key(&self) -> Result<String> {
281 self.snapshot().map(|session| active_api_bearer_token(&session).to_string())
282 }
283
284 pub fn provider_label(&self) -> &'static str {
285 "OpenAI (ChatGPT)"
286 }
287
288 pub async fn refresh_if_needed(&self) -> Result<()> {
289 if !self.auto_refresh {
290 return Ok(());
291 }
292
293 self.refresh_when(|session| session.is_refresh_due()).await
294 }
295
296 pub async fn force_refresh(&self) -> Result<()> {
297 self.refresh_when(|_| true).await
298 }
299
300 async fn refresh_when<P>(&self, should_refresh: P) -> Result<()>
301 where
302 P: FnOnce(&OpenAIChatGptSession) -> bool,
303 {
304 let _refresh_guard = self.refresh_gate.lock().await;
305 let session = self.snapshot()?;
306 if !should_refresh(&session) {
307 return Ok(());
308 }
309
310 let refreshed = match &self.refresh_strategy {
311 OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode } => {
312 refresh_openai_chatgpt_session_from_snapshot(&session, *storage_mode).await?
313 }
314 OpenAIChatGptAuthRefreshStrategy::External { refresher } => refresher.refresh_session(&session).await?,
315 };
316 self.replace_session(refreshed)
317 }
318
319 #[must_use]
320 fn using_external_tokens(&self) -> bool {
321 matches!(self.refresh_strategy, OpenAIChatGptAuthRefreshStrategy::External { .. })
322 }
323
324 fn replace_session(&self, session: OpenAIChatGptSession) -> Result<()> {
325 let mut guard = self.session.lock().map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))?;
326 *guard = session;
327 Ok(())
328 }
329}
330
331#[derive(Clone)]
335pub enum OpenAIResolvedAuth {
336 ApiKey {
337 api_key: String,
338 },
339 ChatGpt {
340 api_key: String,
341 handle: OpenAIChatGptAuthHandle,
342 },
343}
344
345impl fmt::Debug for OpenAIResolvedAuth {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 match self {
348 Self::ApiKey { .. } => f.debug_struct("OpenAIResolvedAuth::ApiKey").finish(),
349 Self::ChatGpt { .. } => f.debug_struct("OpenAIResolvedAuth::ChatGpt").finish(),
350 }
351 }
352}
353
354impl OpenAIResolvedAuth {
355 pub fn api_key(&self) -> &str {
356 match self {
357 Self::ApiKey { api_key } => api_key,
358 Self::ChatGpt { api_key, .. } => api_key,
359 }
360 }
361
362 pub fn handle(&self) -> Option<OpenAIChatGptAuthHandle> {
363 match self {
364 Self::ApiKey { .. } => None,
365 Self::ChatGpt { handle, .. } => Some(handle.clone()),
366 }
367 }
368
369 fn using_chatgpt(&self) -> bool {
370 matches!(self, Self::ChatGpt { .. })
371 }
372}
373
374fn active_api_bearer_token(session: &OpenAIChatGptSession) -> &str {
375 if session.openai_api_key.trim().is_empty() {
376 session.access_token.as_str()
377 } else {
378 session.openai_api_key.as_str()
379 }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub enum OpenAIResolvedAuthSource {
384 ApiKey,
385 ChatGpt,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum OpenAIChatGptSessionProvenance {
392 Native,
394 CodexFallback,
396}
397
398#[derive(Debug, Clone)]
403pub struct OpenAICredentialOverview {
404 pub api_key_available: bool,
405 pub chatgpt_email: Option<String>,
407 pub chatgpt_plan: Option<String>,
409 pub chatgpt_session_present: bool,
411 pub chatgpt_session_provenance: Option<OpenAIChatGptSessionProvenance>,
413 pub codex_fallback_available: bool,
416 pub active_source: Option<OpenAIResolvedAuthSource>,
417 pub preferred_method: OpenAIPreferredMethod,
418 pub notice: Option<String>,
419 pub recommendation: Option<String>,
420}
421
422#[derive(Debug, Clone)]
424pub enum OpenAIChatGptAuthStatus {
425 Authenticated {
426 label: Option<String>,
427 age_seconds: u64,
428 expires_in: Option<u64>,
429 },
430 NotAuthenticated,
431}
432
433pub fn get_openai_chatgpt_auth_url(challenge: &PkceChallenge, callback_port: u16, state: &str) -> Result<String> {
435 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
436 let identity = resolve_oauth_client_identity()?;
437 let query = [
438 ("response_type", "code".to_string()),
439 ("client_id", identity.client_id.clone()),
440 ("redirect_uri", redirect_uri),
441 ("scope", "openid profile email offline_access api.connectors.read api.connectors.invoke".to_string()),
442 ("code_challenge", challenge.code_challenge.clone()),
443 ("code_challenge_method", challenge.code_challenge_method.clone()),
444 ("id_token_add_organizations", "true".to_string()),
445 ("codex_cli_simplified_flow", "true".to_string()),
446 ("state", state.to_string()),
447 ("originator", identity.originator),
448 ];
449
450 let encoded = query
451 .iter()
452 .map(|(key, value)| format!("{key}={}", urlencoding::encode(value)))
453 .collect::<Vec<_>>()
454 .join("&");
455 Ok(format!("{OPENAI_AUTH_URL}?{encoded}"))
456}
457
458pub fn generate_openai_oauth_state() -> Result<String> {
459 let mut state_bytes = [0_u8; 32];
460 SystemRandom::new()
461 .fill(&mut state_bytes)
462 .map_err(|_| anyhow!("failed to generate openai oauth state"))?;
463 Ok(URL_SAFE_NO_PAD.encode(state_bytes))
464}
465
466pub fn parse_openai_chatgpt_manual_callback_input(input: &str, expected_state: &str) -> Result<String> {
467 let trimmed = input.trim();
468 if trimmed.is_empty() {
469 bail!("missing authorization callback input");
470 }
471
472 let query = if trimmed.contains("://") {
473 let url = reqwest::Url::parse(trimmed).context("invalid callback url")?;
474 url.query()
475 .ok_or_else(|| anyhow!("callback url did not include a query string"))?
476 .to_string()
477 } else if trimmed.contains('=') {
478 trimmed.trim_start_matches('?').to_string()
479 } else {
480 bail!("paste the full redirect url or query string containing code and state");
481 };
482
483 let code = extract_query_value(&query, "code")
484 .ok_or_else(|| anyhow!("callback input did not include an authorization code"))?;
485 let state = extract_query_value(&query, "state").ok_or_else(|| anyhow!("callback input did not include state"))?;
486 if state != expected_state {
487 bail!("OAuth error: state mismatch");
488 }
489 Ok(code)
490}
491
492pub async fn exchange_openai_chatgpt_code_for_tokens(
494 code: &str,
495 challenge: &PkceChallenge,
496 callback_port: u16,
497) -> Result<OpenAIChatGptSession> {
498 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
499 let identity = resolve_oauth_client_identity()?;
500 let body = format!(
501 "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
502 urlencoding::encode(code),
503 urlencoding::encode(&redirect_uri),
504 urlencoding::encode(&identity.client_id),
505 urlencoding::encode(&challenge.code_verifier),
506 );
507
508 let token_response: OpenAITokenResponse = Client::new()
509 .post(OPENAI_TOKEN_URL)
510 .header("Content-Type", "application/x-www-form-urlencoded")
511 .body(body)
512 .send()
513 .await
514 .context("failed to exchange openai authorization code")?
515 .error_for_status()
516 .context("openai authorization-code exchange failed")?
517 .json()
518 .await
519 .context("failed to parse openai authorization-code response")?;
520
521 build_session_from_token_response(token_response).await
522}
523
524pub fn resolve_openai_auth(
526 auth_config: &OpenAIAuthConfig,
527 storage_mode: AuthCredentialsStoreMode,
528 api_key: Option<String>,
529) -> Result<OpenAIResolvedAuth> {
530 crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode).resolve_runtime_auth(api_key)
531}
532
533pub fn summarize_openai_credentials(
534 auth_config: &OpenAIAuthConfig,
535 storage_mode: AuthCredentialsStoreMode,
536 api_key: Option<String>,
537) -> Result<OpenAICredentialOverview> {
538 crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode).summarize_credentials(api_key)
539}
540
541pub fn save_openai_chatgpt_session(session: &OpenAIChatGptSession) -> Result<()> {
542 save_openai_chatgpt_session_with_mode(session, AuthCredentialsStoreMode::default())
543}
544
545pub fn save_openai_chatgpt_session_with_mode(
546 session: &OpenAIChatGptSession,
547 mode: AuthCredentialsStoreMode,
548) -> Result<()> {
549 let serialized = serde_json::to_string(session).context("failed to serialize openai session")?;
550 match mode.effective_mode() {
551 AuthCredentialsStoreMode::Keyring => persist_session_to_keyring_or_file(session, &serialized)?,
552 AuthCredentialsStoreMode::File => save_session_to_file(session)?,
553 AuthCredentialsStoreMode::Auto => unreachable!(),
554 }
555 Ok(())
556}
557
558pub fn load_openai_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
559 load_preferred_openai_chatgpt_session(AuthCredentialsStoreMode::Keyring)
560}
561
562pub fn load_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenAIChatGptSession>> {
563 load_preferred_openai_chatgpt_session(mode.effective_mode())
564}
565
566pub fn clear_openai_chatgpt_session() -> Result<()> {
567 clear_session_from_all_stores()
568}
569
570pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
571 match mode.effective_mode() {
572 AuthCredentialsStoreMode::Keyring => clear_session_from_keyring(),
573 AuthCredentialsStoreMode::File => clear_session_from_file(),
574 AuthCredentialsStoreMode::Auto => unreachable!(),
575 }
576}
577
578pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
579 get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
580}
581
582pub fn get_openai_chatgpt_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptAuthStatus> {
583 let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
584 return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
585 };
586 let now = now_secs();
587 Ok(OpenAIChatGptAuthStatus::Authenticated {
588 label: session
589 .email
590 .clone()
591 .or_else(|| session.plan.clone())
592 .or_else(|| session.account_id.clone()),
593 age_seconds: now.saturating_sub(session.obtained_at),
594 expires_in: session.expires_at.map(|expires_at| expires_at.saturating_sub(now)),
595 })
596}
597
598async fn refresh_openai_chatgpt_session_from_refresh_token(
607 refresh_token: &str,
608 storage_mode: AuthCredentialsStoreMode,
609) -> Result<OpenAIChatGptSession> {
610 let _lock = acquire_refresh_lock().await?;
611 let minimal = OpenAIChatGptSession {
614 openai_api_key: String::new(),
615 id_token: String::new(),
616 access_token: String::new(),
617 refresh_token: refresh_token.to_string(),
618 account_id: None,
619 email: None,
620 plan: None,
621 obtained_at: 0,
622 refreshed_at: 0,
623 expires_at: None,
624 };
625 refresh_openai_chatgpt_session_without_lock(&minimal, storage_mode).await
626}
627
628pub async fn refresh_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptSession> {
629 let session = load_openai_chatgpt_session_with_mode(mode)?.ok_or_else(|| anyhow!("Run vtcode login openai"))?;
630 refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
631}
632
633async fn refresh_openai_chatgpt_session_from_snapshot(
634 session: &OpenAIChatGptSession,
635 storage_mode: AuthCredentialsStoreMode,
636) -> Result<OpenAIChatGptSession> {
637 let _lock = acquire_refresh_lock().await?;
638 if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
639 && session_has_newer_refresh_state(¤t, session)
640 {
641 return Ok(current);
642 }
643 refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
644}
645
646async fn refresh_openai_chatgpt_session_without_lock(
653 current: &OpenAIChatGptSession,
654 storage_mode: AuthCredentialsStoreMode,
655) -> Result<OpenAIChatGptSession> {
656 let identity = resolve_oauth_client_identity()?;
657 let response = Client::new()
658 .post(OPENAI_TOKEN_URL)
659 .header("Content-Type", "application/x-www-form-urlencoded")
660 .body(format!(
661 "grant_type=refresh_token&client_id={}&refresh_token={}",
662 urlencoding::encode(&identity.client_id),
663 urlencoding::encode(¤t.refresh_token),
664 ))
665 .send()
666 .await
667 .context("failed to refresh openai chatgpt token")?;
668
669 if !response.status().is_success() {
673 let status = response.status();
674 let body_text = read_bounded_text(response, MAX_ERROR_BODY_BYTES).await;
676 return Err(classify_refresh_status_error(status, &body_text));
677 }
678
679 let refresh_response: OpenAIRefreshResponse =
680 response.json().await.context("failed to parse openai refresh response")?;
681
682 let session = merge_refresh_response(current, refresh_response).await?;
683 if session.access_token.trim().is_empty() {
688 bail!("openai token refresh returned no access token — the session cannot be used");
689 }
690 save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
691 Ok(session)
692}
693
694async fn merge_refresh_response(
698 current: &OpenAIChatGptSession,
699 resp: OpenAIRefreshResponse,
700) -> Result<OpenAIChatGptSession> {
701 let now = now_secs();
702 let has_new_id_token = resp.id_token.as_deref().is_some_and(|v| !v.trim().is_empty());
706 let has_new_access_token = resp.access_token.as_deref().is_some_and(|v| !v.trim().is_empty());
707 let new_id_token = resp
708 .id_token
709 .filter(|v| !v.trim().is_empty())
710 .unwrap_or_else(|| current.id_token.clone());
711 let new_access_token = resp
712 .access_token
713 .filter(|v| !v.trim().is_empty())
714 .unwrap_or_else(|| current.access_token.clone());
715 let new_refresh_token = resp
716 .refresh_token
717 .filter(|v| !v.trim().is_empty())
718 .unwrap_or_else(|| current.refresh_token.clone());
719
720 let openai_api_key = if has_new_id_token {
722 match exchange_openai_chatgpt_api_key(&new_id_token).await {
723 Ok(api_key) => api_key,
724 Err(err) => {
725 tracing::warn!("openai api-key exchange unavailable, falling back to previous key: {err}");
726 current.openai_api_key.clone()
727 }
728 }
729 } else {
730 current.openai_api_key.clone()
731 };
732
733 let access_token_changed = has_new_access_token && new_access_token != current.access_token;
743 let expires_at = if let Some(secs) = resp.expires_in {
744 Some(now.saturating_add(secs))
745 } else if access_token_changed {
746 parse_jwt_exp(&new_access_token)
747 } else {
748 current.expires_at
749 };
750
751 let (email, plan, account_id) = if has_new_id_token {
753 let id_claims = parse_jwt_claims(&new_id_token)?;
754 let access_claims = parse_jwt_claims(&new_access_token).ok();
755 let email = id_claims.email.clone();
756 let plan = access_claims.as_ref().and_then(|c| c.plan.clone()).or(id_claims.plan);
757 let account_id = access_claims
758 .as_ref()
759 .and_then(|c| c.account_id.clone())
760 .or(id_claims.account_id);
761 (email, plan, account_id)
762 } else {
763 (current.email.clone(), current.plan.clone(), current.account_id.clone())
764 };
765
766 Ok(OpenAIChatGptSession {
767 openai_api_key,
768 id_token: new_id_token,
769 access_token: new_access_token,
770 refresh_token: new_refresh_token,
771 account_id,
772 email,
773 plan,
774 obtained_at: current.obtained_at,
776 refreshed_at: now,
777 expires_at,
778 })
779}
780
781async fn build_session_from_token_response(token_response: OpenAITokenResponse) -> Result<OpenAIChatGptSession> {
782 if token_response.access_token.trim().is_empty() {
784 bail!("openai authorization-code response did not include a usable access token");
785 }
786 if token_response.refresh_token.trim().is_empty() {
787 bail!("openai authorization-code response did not include a usable refresh token");
788 }
789 let id_claims = parse_jwt_claims(&token_response.id_token)?;
790 let access_claims = parse_jwt_claims(&token_response.access_token).ok();
791 let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
792 Ok(api_key) => api_key,
793 Err(err) => {
794 tracing::warn!("openai api-key exchange unavailable, falling back to oauth access token: {err}");
795 String::new()
796 }
797 };
798 let now = now_secs();
799 Ok(OpenAIChatGptSession {
800 openai_api_key: api_key,
801 id_token: token_response.id_token,
802 access_token: token_response.access_token,
803 refresh_token: token_response.refresh_token,
804 account_id: access_claims
805 .as_ref()
806 .and_then(|claims| claims.account_id.clone())
807 .or(id_claims.account_id),
808 email: id_claims
809 .email
810 .or_else(|| access_claims.as_ref().and_then(|claims| claims.email.clone())),
811 plan: access_claims.as_ref().and_then(|claims| claims.plan.clone()).or(id_claims.plan),
812 obtained_at: now,
813 refreshed_at: now,
814 expires_at: token_response.expires_in.map(|secs| now.saturating_add(secs)),
815 })
816}
817
818async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
819 #[derive(Deserialize)]
820 struct ExchangeResponse {
821 access_token: String,
822 }
823
824 let identity = resolve_oauth_client_identity()?;
825 let exchange: ExchangeResponse = Client::new()
826 .post(OPENAI_TOKEN_URL)
827 .header("Content-Type", "application/x-www-form-urlencoded")
828 .body(format!(
829 "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
830 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
831 urlencoding::encode(&identity.client_id),
832 urlencoding::encode("openai-api-key"),
833 urlencoding::encode(id_token),
834 urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
835 ))
836 .send()
837 .await
838 .context("failed to exchange openai id token for api key")?
839 .error_for_status()
840 .context("openai api-key exchange failed")?
841 .json()
842 .await
843 .context("failed to parse openai api-key exchange response")?;
844
845 Ok(exchange.access_token)
846}
847
848#[derive(Deserialize)]
849struct OpenAITokenResponse {
850 id_token: String,
851 access_token: String,
852 refresh_token: String,
853 #[serde(default)]
854 expires_in: Option<u64>,
855}
856
857#[derive(Deserialize)]
862struct OpenAIRefreshResponse {
863 #[serde(default)]
864 id_token: Option<String>,
865 #[serde(default)]
866 access_token: Option<String>,
867 #[serde(default)]
868 refresh_token: Option<String>,
869 #[serde(default)]
870 expires_in: Option<u64>,
871}
872
873#[derive(Debug, Deserialize)]
874struct IdTokenClaims {
875 #[serde(default)]
876 email: Option<String>,
877 #[serde(rename = "https://api.openai.com/profile", default)]
878 profile: Option<ProfileClaims>,
879 #[serde(rename = "https://api.openai.com/auth", default)]
880 auth: Option<AuthClaims>,
881}
882
883#[derive(Debug, Deserialize)]
884struct ProfileClaims {
885 #[serde(default)]
886 email: Option<String>,
887}
888
889#[derive(Debug, Deserialize)]
890struct AuthClaims {
891 #[serde(default)]
892 chatgpt_plan_type: Option<String>,
893 #[serde(default)]
894 chatgpt_account_id: Option<String>,
895}
896
897#[derive(Debug)]
898pub(crate) struct ParsedIdTokenClaims {
899 pub(crate) email: Option<String>,
900 pub(crate) account_id: Option<String>,
901 pub(crate) plan: Option<String>,
902}
903
904pub(crate) fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
905 let mut parts = jwt.split('.');
906 let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
907 (Some(header), Some(payload), Some(signature))
908 if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
909 {
910 (header, payload, signature)
911 }
912 _ => bail!("invalid openai id token"),
913 };
914
915 let payload = URL_SAFE_NO_PAD
916 .decode(payload_b64)
917 .context("failed to decode openai id token payload")?;
918 let claims: IdTokenClaims = serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
919
920 Ok(ParsedIdTokenClaims {
921 email: claims.email.or_else(|| claims.profile.and_then(|profile| profile.email)),
922 account_id: claims.auth.as_ref().and_then(|auth| auth.chatgpt_account_id.clone()),
923 plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
924 })
925}
926
927pub(crate) fn parse_jwt_exp(jwt: &str) -> Option<u64> {
933 let mut parts = jwt.split('.');
934 let _ = parts.next()?;
935 let payload_b64 = parts.next()?;
936 if payload_b64.is_empty() {
937 return None;
938 }
939 let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
940 #[derive(Deserialize)]
941 struct ExpClaim {
942 #[serde(default)]
943 exp: Option<u64>,
944 }
945 let claims: ExpClaim = serde_json::from_slice(&payload).ok()?;
946 claims.exp
947}
948
949fn extract_query_value(query: &str, key: &str) -> Option<String> {
950 query
951 .trim_start_matches('?')
952 .split('&')
953 .filter_map(|pair| {
954 let (pair_key, pair_value) = pair.split_once('=')?;
955 (pair_key == key)
956 .then(|| urlencoding::decode(pair_value).ok().map(|value| value.into_owned()))
957 .flatten()
958 })
959 .find(|value| !value.is_empty())
960}
961
962fn session_has_newer_refresh_state(current: &OpenAIChatGptSession, previous: &OpenAIChatGptSession) -> bool {
963 current.refresh_token != previous.refresh_token
964 || current.refreshed_at > previous.refreshed_at
965 || current.obtained_at > previous.obtained_at
966}
967
968struct RefreshLockGuard {
969 file: fs::File,
970}
971
972impl Drop for RefreshLockGuard {
973 fn drop(&mut self) {
974 let _ = FileExt::unlock(&self.file);
975 }
976}
977
978async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
979 let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
980 let file = OpenOptions::new()
981 .create(true)
982 .read(true)
983 .write(true)
984 .truncate(false)
985 .open(&path)
986 .context("failed to open openai refresh lock")?;
987 let file = tokio::task::spawn_blocking(move || {
988 file.lock_exclusive().context("failed to acquire openai refresh lock")?;
989 Ok::<_, anyhow::Error>(file)
990 })
991 .await
992 .context("openai refresh lock task failed")??;
993 Ok(RefreshLockGuard { file })
994}
995
996async fn read_bounded_text(mut response: reqwest::Response, max_bytes: usize) -> String {
1004 let mut buf = Vec::with_capacity(max_bytes.min(8 * 1024));
1005 while buf.len() < max_bytes {
1006 match response.chunk().await {
1007 Ok(Some(chunk)) => {
1008 let remaining = max_bytes - buf.len();
1009 if chunk.len() <= remaining {
1010 buf.extend_from_slice(&chunk);
1011 } else {
1012 buf.extend_from_slice(&chunk[..remaining]);
1013 break;
1014 }
1015 }
1016 Ok(None) => break,
1017 Err(_) => break,
1018 }
1019 }
1020 String::from_utf8_lossy(&buf).into_owned()
1021}
1022
1023fn extract_error_code(body: &str) -> String {
1036 #[derive(Deserialize)]
1038 struct FlatErrorResponse {
1039 #[serde(default)]
1040 error: Option<serde_json::Value>,
1041 #[serde(default)]
1043 code: Option<String>,
1044 }
1045
1046 if let Ok(parsed) = serde_json::from_str::<FlatErrorResponse>(body) {
1047 if let Some(serde_json::Value::String(s)) = &parsed.error
1049 && !s.trim().is_empty()
1050 {
1051 return s.to_lowercase();
1052 }
1053
1054 if let Some(serde_json::Value::Object(obj)) = &parsed.error {
1061 let code = obj
1062 .get("code")
1063 .or_else(|| obj.get("type"))
1064 .and_then(|v| v.as_str())
1065 .filter(|s| !s.trim().is_empty());
1066 if let Some(c) = code {
1067 return c.to_lowercase();
1068 }
1069 }
1070
1071 if let Some(code) = &parsed.code
1073 && !code.trim().is_empty()
1074 {
1075 return code.to_lowercase();
1076 }
1077 }
1078
1079 String::new()
1080}
1081
1082#[cold]
1116fn classify_refresh_status_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
1117 let error_code = extract_error_code(body);
1118
1119 const TERMINAL_GRANT_CODES: &[&str] = &[
1123 "invalid_grant",
1124 "invalid_token",
1125 "refresh_token_expired",
1126 "refresh_token_revoked",
1127 "refresh_token_reused",
1128 "refresh_token_invalidated",
1129 ];
1130
1131 let is_client_error = status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::UNAUTHORIZED;
1135 let is_terminal_grant =
1136 is_client_error && error_code != String::new() && TERMINAL_GRANT_CODES.iter().any(|code| error_code == *code);
1137
1138 if is_terminal_grant {
1139 if let Err(clear_err) = clear_session_from_all_stores() {
1140 tracing::warn!("failed to clear expired openai chatgpt session across all stores: {clear_err}");
1141 }
1142 anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
1143 } else if error_code == "invalid_client" {
1144 anyhow!(
1147 "openai token refresh failed (HTTP {status}, invalid_client) — \
1148 check your VTCODE_OPENAI_OAUTH_CLIENT_ID / VTCODE_OPENAI_OAUTH_ORIGINATOR configuration"
1149 )
1150 } else if status == reqwest::StatusCode::UNAUTHORIZED {
1151 anyhow!("openai token refresh failed (HTTP {status}) — check your OAuth client configuration and retry")
1155 } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1156 anyhow!("openai token refresh was rate-limited (HTTP {status}) — retry later")
1158 } else {
1159 anyhow!("openai token refresh failed (HTTP {status})")
1161 }
1162}
1163
1164fn clear_session_from_all_stores() -> Result<()> {
1165 let mut errors = Vec::new();
1166
1167 if let Err(err) = clear_session_from_keyring() {
1168 errors.push(err.to_string());
1169 }
1170 if let Err(err) = clear_session_from_file() {
1171 errors.push(err.to_string());
1172 }
1173
1174 if errors.is_empty() {
1175 Ok(())
1176 } else {
1177 Err(anyhow!("failed to clear openai session from all stores: {}", errors.join("; ")))
1178 }
1179}
1180
1181fn save_session_to_keyring(serialized: &str) -> Result<()> {
1182 let entry = keyring::entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
1183 .context("failed to access keyring for openai session")?;
1184 entry
1185 .set_password(serialized)
1186 .context("failed to store openai session in keyring")?;
1187 Ok(())
1188}
1189
1190fn persist_session_to_keyring_or_file(session: &OpenAIChatGptSession, serialized: &str) -> Result<()> {
1191 match save_session_to_keyring(serialized) {
1192 Ok(()) => match load_session_from_keyring_decoded() {
1193 Ok(Some(_)) => Ok(()),
1194 Ok(None) => {
1195 tracing::warn!(
1196 "openai session keyring write did not round-trip; falling back to encrypted file storage"
1197 );
1198 save_session_to_file(session)
1199 }
1200 Err(err) => {
1201 tracing::warn!(
1202 "openai session keyring verification failed, falling back to encrypted file storage: {err}"
1203 );
1204 save_session_to_file(session)
1205 }
1206 },
1207 Err(err) => {
1208 tracing::warn!(
1209 "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
1210 );
1211 save_session_to_file(session).context("failed to persist openai session after keyring fallback")
1212 }
1213 }
1214}
1215
1216fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
1217 serde_json::from_str(&serialized).context("failed to decode openai session")
1218}
1219
1220fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
1221 load_session_from_keyring()?.map(decode_session_from_keyring).transpose()
1222}
1223
1224fn load_preferred_openai_chatgpt_session(mode: AuthCredentialsStoreMode) -> Result<Option<OpenAIChatGptSession>> {
1225 match mode {
1226 AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
1227 Ok(Some(session)) => Ok(Some(session)),
1228 Ok(None) => load_session_from_file(),
1229 Err(err) => {
1230 tracing::warn!("failed to load openai session from keyring, falling back to encrypted file: {err}");
1231 load_session_from_file()
1232 }
1233 },
1234 AuthCredentialsStoreMode::File => {
1235 if let Some(session) = load_session_from_file()? {
1236 return Ok(Some(session));
1237 }
1238 load_session_from_keyring_decoded()
1239 }
1240 AuthCredentialsStoreMode::Auto => unreachable!(),
1241 }
1242}
1243
1244fn load_session_from_keyring() -> Result<Option<String>> {
1245 let entry = match keyring::entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
1246 Ok(entry) => entry,
1247 Err(_) => return Ok(None),
1248 };
1249
1250 match entry.get_password() {
1251 Ok(value) => Ok(Some(value)),
1252 Err(keyring_core::Error::NoEntry) => Ok(None),
1253 Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
1254 }
1255}
1256
1257fn clear_session_from_keyring() -> Result<()> {
1258 let entry = match keyring::entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
1259 Ok(entry) => entry,
1260 Err(_) => return Ok(()),
1261 };
1262
1263 match entry.delete_credential() {
1264 Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
1265 Err(err) => Err(anyhow!("failed to clear openai session keyring entry: {err}")),
1266 }
1267}
1268
1269fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
1270 let encrypted = encrypt_session(session)?;
1271 let path = get_session_path()?;
1272 let payload = serde_json::to_vec_pretty(&encrypted)?;
1273 write_private_file(&path, &payload).context("failed to persist openai session file")?;
1274 Ok(())
1275}
1276
1277fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
1278 let path = get_session_path()?;
1279 let data = match fs::read(path) {
1280 Ok(data) => data,
1281 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1282 Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
1283 };
1284
1285 let encrypted: EncryptedSession = serde_json::from_slice(&data).context("failed to decode openai session file")?;
1286 Ok(Some(decrypt_session(&encrypted)?))
1287}
1288
1289fn clear_session_from_file() -> Result<()> {
1290 let path = get_session_path()?;
1291 match fs::remove_file(path) {
1292 Ok(()) => Ok(()),
1293 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1294 Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
1295 }
1296}
1297
1298fn get_session_path() -> Result<PathBuf> {
1299 Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
1300}
1301
1302#[derive(Debug, Serialize, Deserialize)]
1303struct EncryptedSession {
1304 nonce: String,
1305 ciphertext: String,
1306 version: u8,
1307}
1308
1309fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
1310 let key = derive_encryption_key()?;
1311 let rng = SystemRandom::new();
1312 let mut nonce_bytes = [0u8; NONCE_LEN];
1313 rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("failed to generate nonce"))?;
1314
1315 let mut ciphertext = serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
1316 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
1317 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
1318 .map_err(|_| anyhow!("failed to encrypt openai session"))?;
1319
1320 Ok(EncryptedSession {
1321 nonce: STANDARD.encode(nonce_bytes),
1322 ciphertext: STANDARD.encode(ciphertext),
1323 version: 1,
1324 })
1325}
1326
1327fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
1328 if encrypted.version != 1 {
1329 bail!("unsupported openai session encryption format");
1330 }
1331
1332 let nonce_bytes = STANDARD
1333 .decode(&encrypted.nonce)
1334 .context("failed to decode openai session nonce")?;
1335 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
1336 .try_into()
1337 .map_err(|_| anyhow!("invalid openai session nonce length"))?;
1338 let mut ciphertext = STANDARD
1339 .decode(&encrypted.ciphertext)
1340 .context("failed to decode openai session ciphertext")?;
1341
1342 let key = derive_encryption_key()?;
1343 let plaintext = key
1344 .open_in_place(Nonce::assume_unique_for_key(nonce_array), Aad::empty(), &mut ciphertext)
1345 .map_err(|_| anyhow!("failed to decrypt openai session"))?;
1346 serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
1347}
1348
1349fn derive_encryption_key() -> Result<LessSafeKey> {
1350 use ring::digest::{SHA256, digest};
1351
1352 let mut key_material = Vec::new();
1353 if let Ok(hostname) = hostname::get() {
1354 key_material.extend_from_slice(hostname.as_encoded_bytes());
1355 }
1356
1357 #[cfg(unix)]
1358 {
1359 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
1360 }
1361 #[cfg(not(unix))]
1362 {
1363 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
1364 key_material.extend_from_slice(user.as_bytes());
1365 }
1366 }
1367
1368 key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
1369 let hash = digest(&SHA256, &key_material);
1370 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
1371 .try_into()
1372 .context("openai session encryption key was too short")?;
1373 let unbound =
1374 UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("invalid openai session encryption key"))?;
1375 Ok(LessSafeKey::new(unbound))
1376}
1377
1378fn now_secs() -> u64 {
1379 std::time::SystemTime::now()
1380 .duration_since(std::time::UNIX_EPOCH)
1381 .map(|duration| duration.as_secs())
1382 .unwrap_or(0)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388 use crate::AuthCallbackOutcome;
1389 use crate::generate_pkce_challenge;
1390 use assert_fs::TempDir;
1391 use serial_test::serial;
1392 use std::sync::Arc;
1393
1394 struct ExternalRefresher;
1395
1396 #[async_trait]
1397 impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1398 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1399 let mut refreshed = current.clone();
1400 refreshed.access_token = "oauth-access-refreshed".to_string();
1401 refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1402 refreshed.expires_at = Some(now_secs() + 3600);
1403 Ok(refreshed)
1404 }
1405 }
1406
1407 struct TestAuthDirGuard {
1408 temp_dir: Option<TempDir>,
1409 codex_temp_dir: Option<TempDir>,
1410 previous: Option<PathBuf>,
1411 previous_codex_home: Option<String>,
1412 }
1413
1414 impl TestAuthDirGuard {
1415 fn new() -> Self {
1416 let temp_dir = TempDir::new().expect("create temp auth dir");
1417 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
1418 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
1419 .expect("set temp auth dir override");
1420
1421 let codex_temp_dir = TempDir::new().expect("create temp codex home");
1424 let previous_codex_home = std::env::var("CODEX_HOME").ok();
1425 vtcode_commons::env_lock::set_var("CODEX_HOME", codex_temp_dir.path());
1426
1427 Self {
1428 temp_dir: Some(temp_dir),
1429 codex_temp_dir: Some(codex_temp_dir),
1430 previous,
1431 previous_codex_home,
1432 }
1433 }
1434 }
1435
1436 impl Drop for TestAuthDirGuard {
1437 fn drop(&mut self) {
1438 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1439 .expect("restore auth dir override");
1440 if let Some(temp_dir) = self.temp_dir.take() {
1441 temp_dir.close().expect("remove temp auth dir");
1442 }
1443 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", self.previous_codex_home.as_deref());
1444 if let Some(codex_temp_dir) = self.codex_temp_dir.take() {
1445 codex_temp_dir.close().expect("remove temp codex home");
1446 }
1447 }
1448 }
1449
1450 fn sample_session() -> OpenAIChatGptSession {
1451 OpenAIChatGptSession {
1452 openai_api_key: "api-key".to_string(),
1453 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1454 access_token: "oauth-access".to_string(),
1455 refresh_token: "refresh-token".to_string(),
1456 account_id: Some("acc_123".to_string()),
1457 email: Some("test@example.com".to_string()),
1458 plan: Some("plus".to_string()),
1459 obtained_at: 10,
1460 refreshed_at: 10,
1461 expires_at: Some(now_secs() + 3600),
1462 }
1463 }
1464
1465 #[test]
1466 fn auth_url_contains_expected_openai_parameters() {
1467 let env = OauthEnvGuard::new();
1469 env.remove_client_id();
1470 env.remove_originator();
1471
1472 let challenge = PkceChallenge {
1473 code_verifier: "verifier".to_string(),
1474 code_challenge: "challenge".to_string(),
1475 code_challenge_method: "S256".to_string(),
1476 };
1477
1478 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1479 assert!(url.starts_with(OPENAI_AUTH_URL));
1480 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1481 assert!(url.contains("code_challenge=challenge"));
1482 assert!(url.contains("codex_cli_simplified_flow=true"));
1483 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1484 assert!(url.contains("state=test-state"));
1485 }
1486
1487 #[test]
1488 fn auth_url_honors_custom_client_id_env_override() {
1489 let env = OauthEnvGuard::new();
1491 env.set_client_id("app_custom_override");
1492 env.set_originator("vtcode_custom");
1493
1494 let challenge = PkceChallenge {
1495 code_verifier: "verifier".to_string(),
1496 code_challenge: "challenge".to_string(),
1497 code_challenge_method: "S256".to_string(),
1498 };
1499 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1500 assert!(url.contains("client_id=app_custom_override"), "custom client_id not used: {url}");
1501 assert!(url.contains("originator=vtcode_custom"), "custom originator not used: {url}");
1502 assert!(!url.contains("app_EMoamEEZ73f0CkXaXp7hrann"), "default client_id leaked through override: {url}");
1503 }
1504
1505 struct OauthEnvGuard {
1509 env: vtcode_commons::env_lock::EnvGuard,
1510 prev_client_id: Option<std::ffi::OsString>,
1511 prev_originator: Option<std::ffi::OsString>,
1512 }
1513
1514 impl OauthEnvGuard {
1515 fn new() -> Self {
1516 let env = vtcode_commons::env_lock::lock();
1517 let prev_client_id = std::env::var_os("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1518 let prev_originator = std::env::var_os("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1519 Self { env, prev_client_id, prev_originator }
1520 }
1521
1522 fn set_client_id(&self, value: &str) {
1523 self.env.set_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", value);
1524 }
1525
1526 fn set_originator(&self, value: &str) {
1527 self.env.set_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", value);
1528 }
1529
1530 fn remove_client_id(&self) {
1531 self.env.remove_var("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1532 }
1533
1534 fn remove_originator(&self) {
1535 self.env.remove_var("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1536 }
1537 }
1538
1539 impl Drop for OauthEnvGuard {
1540 fn drop(&mut self) {
1541 self.env
1542 .restore_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", self.prev_client_id.take());
1543 self.env
1544 .restore_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", self.prev_originator.take());
1545 }
1546 }
1547
1548 #[test]
1549 fn resolve_oauth_client_identity_both_defaults() {
1550 let env = OauthEnvGuard::new();
1551 env.remove_client_id();
1552 env.remove_originator();
1553
1554 let identity = resolve_oauth_client_identity().expect("defaults");
1555 assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1556 assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1557 }
1558
1559 #[test]
1560 fn resolve_oauth_client_identity_both_custom() {
1561 let env = OauthEnvGuard::new();
1562 env.set_client_id("app_custom");
1563 env.set_originator("my_originator");
1564
1565 let identity = resolve_oauth_client_identity().expect("custom pair");
1566 assert_eq!(identity.client_id, "app_custom");
1567 assert_eq!(identity.originator, "my_originator");
1568 }
1569
1570 #[test]
1571 fn resolve_oauth_client_identity_only_client_id_is_error() {
1572 let env = OauthEnvGuard::new();
1573 env.set_client_id("app_custom");
1574 env.remove_originator();
1575
1576 let err = resolve_oauth_client_identity().unwrap_err();
1577 let msg = err.to_string();
1578 assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the set var: {msg}");
1579 assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the missing var: {msg}");
1580 }
1581
1582 #[test]
1583 fn resolve_oauth_client_identity_only_originator_is_error() {
1584 let env = OauthEnvGuard::new();
1585 env.remove_client_id();
1586 env.set_originator("my_originator");
1587
1588 let err = resolve_oauth_client_identity().unwrap_err();
1589 let msg = err.to_string();
1590 assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the set var: {msg}");
1591 assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the missing var: {msg}");
1592 }
1593
1594 #[test]
1595 fn resolve_oauth_client_identity_blank_values_treated_as_unset() {
1596 let env = OauthEnvGuard::new();
1597 env.set_client_id(" ");
1598 env.set_originator(" ");
1599
1600 let identity = resolve_oauth_client_identity().expect("defaults from blank");
1602 assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1603 assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1604 }
1605
1606 #[test]
1607 fn parse_jwt_claims_extracts_openai_claims() {
1608 let claims = parse_jwt_claims(
1609 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1610 )
1611 .expect("claims");
1612 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1613 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1614 assert_eq!(claims.plan.as_deref(), Some("plus"));
1615 }
1616
1617 #[test]
1618 fn session_refresh_due_uses_expiry_and_age() {
1619 let mut session = sample_session();
1620 let now = now_secs();
1621 session.obtained_at = now;
1622 session.refreshed_at = now;
1623 session.expires_at = Some(now + 3600);
1624 assert!(!session.is_refresh_due());
1625 session.expires_at = Some(now);
1626 assert!(session.is_refresh_due());
1627 }
1628
1629 #[tokio::test]
1630 #[serial]
1631 async fn external_auth_handle_refreshes_without_persisting_session() {
1632 let _guard = TestAuthDirGuard::new();
1633 let mut session = sample_session();
1634 session.openai_api_key.clear();
1635 session.expires_at = Some(now_secs().saturating_sub(1));
1636 let handle = OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1637
1638 assert!(handle.using_external_tokens());
1639 assert!(
1640 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1641 .expect("load session")
1642 .is_none()
1643 );
1644
1645 handle.force_refresh().await.expect("force refresh");
1646
1647 assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1648 assert!(
1649 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1650 .expect("load session")
1651 .is_none()
1652 );
1653 }
1654
1655 struct CountingExternalRefresher {
1656 calls: Arc<Mutex<usize>>,
1657 }
1658
1659 #[async_trait]
1660 impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1661 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1662 let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1663 *calls += 1;
1664 drop(calls);
1665
1666 let mut refreshed = current.clone();
1667 refreshed.access_token = "oauth-access-refreshed".to_string();
1668 refreshed.refreshed_at = now_secs();
1669 refreshed.expires_at = Some(now_secs() + 3600);
1670 Ok(refreshed)
1671 }
1672 }
1673
1674 #[tokio::test]
1675 async fn refresh_if_needed_serializes_external_refreshes() {
1676 let mut session = sample_session();
1677 session.openai_api_key.clear();
1678 session.expires_at = Some(now_secs().saturating_sub(1));
1679 let calls = Arc::new(Mutex::new(0usize));
1680 let handle = OpenAIChatGptAuthHandle::new_external(
1681 session,
1682 true,
1683 Arc::new(CountingExternalRefresher { calls: Arc::clone(&calls) }),
1684 );
1685
1686 let first = handle.clone();
1687 let second = handle.clone();
1688 let (first_result, second_result) = tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1689
1690 first_result.expect("first refresh should succeed");
1691 second_result.expect("second refresh should succeed");
1692 assert_eq!(
1693 *calls.lock().expect("refresh calls mutex should lock"),
1694 1,
1695 "concurrent refresh_if_needed calls should share one refresh"
1696 );
1697 assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1698 }
1699
1700 #[test]
1701 #[serial]
1702 fn resolve_openai_auth_prefers_chatgpt_in_auto_permission() {
1703 let _guard = TestAuthDirGuard::new();
1704 let session = sample_session();
1705 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1706 let resolved = resolve_openai_auth(
1707 &OpenAIAuthConfig::default(),
1708 AuthCredentialsStoreMode::File,
1709 Some("api-key".to_string()),
1710 )
1711 .expect("resolved auth");
1712 assert!(resolved.using_chatgpt());
1713 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1714 }
1715
1716 #[test]
1717 #[serial]
1718 #[cfg(unix)]
1719 fn file_storage_uses_private_permissions() {
1720 use std::fs;
1721 use std::os::unix::fs::PermissionsExt;
1722
1723 let _guard = TestAuthDirGuard::new();
1724 let session = sample_session();
1725
1726 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1727
1728 let metadata = fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1729 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1730 }
1731
1732 #[test]
1733 #[serial]
1734 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1735 let _guard = TestAuthDirGuard::new();
1736 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1737 let resolved = resolve_openai_auth(
1738 &OpenAIAuthConfig::default(),
1739 AuthCredentialsStoreMode::File,
1740 Some("api-key".to_string()),
1741 )
1742 .expect("resolved auth");
1743 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1744 }
1745
1746 #[test]
1747 #[serial]
1748 fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1749 let _guard = TestAuthDirGuard::new();
1750 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1751 let error =
1752 resolve_openai_auth(&OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File, Some(" ".to_string()))
1753 .expect_err("blank api key should fail");
1754 assert!(error.to_string().contains("OpenAI API key not found"));
1755 }
1756
1757 #[test]
1758 #[serial]
1759 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1760 let _guard = TestAuthDirGuard::new();
1761 let session = sample_session();
1762 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1763 let resolved = resolve_openai_auth(
1764 &OpenAIAuthConfig {
1765 preferred_method: OpenAIPreferredMethod::ApiKey,
1766 ..OpenAIAuthConfig::default()
1767 },
1768 AuthCredentialsStoreMode::File,
1769 Some("api-key".to_string()),
1770 )
1771 .expect("resolved auth");
1772 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1773 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1774 }
1775
1776 #[test]
1777 #[serial]
1778 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1779 let _guard = TestAuthDirGuard::new();
1780 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1781 let error = resolve_openai_auth(
1782 &OpenAIAuthConfig {
1783 preferred_method: OpenAIPreferredMethod::Chatgpt,
1784 ..OpenAIAuthConfig::default()
1785 },
1786 AuthCredentialsStoreMode::File,
1787 Some("api-key".to_string()),
1788 )
1789 .expect_err("chatgpt mode should require a stored session");
1790 assert!(error.to_string().contains("vtcode login openai"));
1791 }
1792
1793 #[test]
1794 #[serial]
1795 fn summarize_openai_credentials_reports_dual_source_notice() {
1796 let _guard = TestAuthDirGuard::new();
1797 let session = sample_session();
1798 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1799 let overview = summarize_openai_credentials(
1800 &OpenAIAuthConfig::default(),
1801 AuthCredentialsStoreMode::File,
1802 Some("api-key".to_string()),
1803 )
1804 .expect("overview");
1805 assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ChatGpt));
1806 assert!(overview.notice.is_some());
1807 assert!(overview.recommendation.is_some());
1808 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1809 }
1810
1811 #[test]
1812 #[serial]
1813 fn summarize_openai_credentials_respects_api_key_preference() {
1814 let _guard = TestAuthDirGuard::new();
1815 let session = sample_session();
1816 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1817 let overview = summarize_openai_credentials(
1818 &OpenAIAuthConfig {
1819 preferred_method: OpenAIPreferredMethod::ApiKey,
1820 ..OpenAIAuthConfig::default()
1821 },
1822 AuthCredentialsStoreMode::File,
1823 Some("api-key".to_string()),
1824 )
1825 .expect("overview");
1826 assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ApiKey));
1827 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1828 }
1829
1830 #[test]
1831 fn encrypted_file_round_trip_restores_session() {
1832 let session = sample_session();
1833 let encrypted = encrypt_session(&session).expect("encrypt");
1834 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1835 assert_eq!(decrypted.account_id, session.account_id);
1836 assert_eq!(decrypted.email, session.email);
1837 assert_eq!(decrypted.plan, session.plan);
1838 }
1839
1840 #[test]
1841 #[serial]
1842 fn default_loader_falls_back_to_file_session() {
1843 let _guard = TestAuthDirGuard::new();
1844 let session = sample_session();
1845 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1846
1847 let loaded = load_openai_chatgpt_session()
1848 .expect("load session")
1849 .expect("stored session should be found");
1850
1851 assert_eq!(loaded.account_id, session.account_id);
1852 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1853 }
1854
1855 #[test]
1856 #[serial]
1857 fn keyring_mode_loader_falls_back_to_file_session() {
1858 let _guard = TestAuthDirGuard::new();
1859 let session = sample_session();
1860 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1861
1862 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1863 .expect("load session")
1864 .expect("stored session should be found");
1865
1866 assert_eq!(loaded.email, session.email);
1867 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1868 }
1869
1870 #[test]
1871 #[serial]
1872 fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1873 let _guard = TestAuthDirGuard::new();
1874 let session = sample_session();
1875 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save file session");
1876
1877 if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring).is_err() {
1878 clear_openai_chatgpt_session().expect("clear session");
1879 assert!(
1880 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1881 .expect("load file session")
1882 .is_none()
1883 );
1884 return;
1885 }
1886
1887 clear_openai_chatgpt_session().expect("clear session");
1888 assert!(
1889 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1890 .expect("load file session")
1891 .is_none()
1892 );
1893 assert!(
1894 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1895 .expect("load keyring session")
1896 .is_none()
1897 );
1898 }
1899
1900 #[test]
1901 fn active_api_bearer_token_falls_back_to_access_token() {
1902 let mut session = sample_session();
1903 session.openai_api_key.clear();
1904
1905 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1906 }
1907
1908 #[test]
1909 fn parse_manual_callback_input_accepts_full_redirect_url() {
1910 let code = parse_openai_chatgpt_manual_callback_input(
1911 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1912 "test-state",
1913 )
1914 .expect("manual input should parse");
1915 assert_eq!(code, "auth-code");
1916 }
1917
1918 #[test]
1919 fn parse_manual_callback_input_accepts_query_string() {
1920 let code = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=test-state", "test-state")
1921 .expect("manual input should parse");
1922 assert_eq!(code, "auth-code");
1923 }
1924
1925 #[test]
1926 fn parse_manual_callback_input_rejects_bare_code() {
1927 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1928 .expect_err("bare code should be rejected");
1929 assert!(error.to_string().contains("full redirect url or query string"));
1930 }
1931
1932 #[test]
1933 fn parse_manual_callback_input_rejects_state_mismatch() {
1934 let error = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=wrong-state", "test-state")
1935 .expect_err("state mismatch should fail");
1936 assert!(error.to_string().contains("state mismatch"));
1937 }
1938
1939 #[tokio::test]
1940 #[serial]
1941 async fn refresh_lock_serializes_parallel_acquisition() {
1942 let _guard = TestAuthDirGuard::new();
1943 let first = tokio::spawn(async {
1944 let _lock = acquire_refresh_lock().await.expect("first lock");
1945 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1946 });
1947 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1948
1949 let start = std::time::Instant::now();
1950 let second = tokio::spawn(async {
1951 let _lock = acquire_refresh_lock().await.expect("second lock");
1952 });
1953
1954 first.await.expect("first task");
1955 second.await.expect("second task");
1956 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1957 }
1958
1959 #[test]
1962 fn debug_impl_redacts_all_token_fields() {
1963 let session = sample_session();
1964 let debug_str = format!("{session:?}");
1965 assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
1967 assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
1968 assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
1969 assert!(!debug_str.contains("eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20i"), "id_token leaked: {debug_str}");
1971 assert!(debug_str.contains("test@example.com"), "email should be visible: {debug_str}");
1973 assert!(debug_str.contains("plus"), "plan should be visible: {debug_str}");
1974 }
1975
1976 #[test]
1977 fn debug_impl_redacts_resolved_auth_api_key() {
1978 let resolved = OpenAIResolvedAuth::ApiKey { api_key: "sk-secret-key".to_string() };
1979 let debug_str = format!("{resolved:?}");
1980 assert!(!debug_str.contains("sk-secret-key"), "api_key leaked: {debug_str}");
1981 }
1982
1983 #[test]
1984 fn debug_impl_redacts_resolved_auth_chatgpt_handle() {
1985 let _guard = TestAuthDirGuard::new();
1986 let session = sample_session();
1987 let handle = OpenAIChatGptAuthHandle::new(session, OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File);
1988 let resolved = OpenAIResolvedAuth::ChatGpt { api_key: "sk-secret-bearer".to_string(), handle };
1989 let debug_str = format!("{resolved:?}");
1990 assert!(!debug_str.contains("sk-secret-bearer"), "bearer leaked: {debug_str}");
1991 }
1992
1993 #[test]
1994 fn credential_overview_carries_no_token_fields() {
1995 let _guard = TestAuthDirGuard::new();
1996 let session = sample_session();
1997 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1998 let overview = summarize_openai_credentials(
1999 &OpenAIAuthConfig::default(),
2000 AuthCredentialsStoreMode::File,
2001 Some("sk-overview-key".to_string()),
2002 )
2003 .expect("overview");
2004 let debug_str = format!("{overview:?}");
2007 assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
2008 assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
2009 assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
2010 assert!(!debug_str.contains("sk-overview-key"), "api key value leaked: {debug_str}");
2012 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear");
2013 }
2014
2015 #[tokio::test]
2022 async fn merge_preserves_omitted_access_token() {
2023 let current = sample_session();
2024 let resp = OpenAIRefreshResponse {
2025 id_token: None,
2026 access_token: None,
2027 refresh_token: Some("new-refresh".to_string()),
2028 expires_in: Some(3600),
2029 };
2030 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2031 assert_eq!(merged.access_token, current.access_token, "omitted access_token should be preserved");
2032 assert_eq!(merged.refresh_token, "new-refresh");
2033 }
2034
2035 #[tokio::test]
2036 async fn merge_preserves_omitted_refresh_token() {
2037 let current = sample_session();
2038 let resp = OpenAIRefreshResponse {
2039 id_token: None,
2040 access_token: Some("new-access".to_string()),
2041 refresh_token: None,
2042 expires_in: None,
2043 };
2044 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2045 assert_eq!(merged.refresh_token, current.refresh_token, "omitted refresh_token should be preserved");
2046 assert_eq!(merged.access_token, "new-access");
2047 }
2048
2049 #[tokio::test]
2050 async fn merge_preserves_omitted_id_token_and_api_key() {
2051 let current = sample_session();
2052 let resp = OpenAIRefreshResponse {
2053 id_token: None,
2054 access_token: Some("new-access".to_string()),
2055 refresh_token: None,
2056 expires_in: Some(1800),
2057 };
2058 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2059 assert_eq!(merged.id_token, current.id_token, "omitted id_token should be preserved");
2061 assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key should be preserved without new id_token");
2062 assert_eq!(merged.email, current.email);
2064 assert_eq!(merged.plan, current.plan);
2065 }
2066
2067 #[tokio::test]
2068 async fn merge_all_omitted_preserves_everything() {
2069 let current = sample_session();
2070 let resp = OpenAIRefreshResponse {
2071 id_token: None,
2072 access_token: None,
2073 refresh_token: None,
2074 expires_in: None,
2075 };
2076 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2077 assert_eq!(merged.id_token, current.id_token);
2078 assert_eq!(merged.access_token, current.access_token);
2079 assert_eq!(merged.refresh_token, current.refresh_token);
2080 assert_eq!(merged.openai_api_key, current.openai_api_key);
2081 assert_eq!(merged.email, current.email);
2082 assert_eq!(merged.expires_at, current.expires_at);
2084 }
2085
2086 #[tokio::test]
2087 async fn merge_new_access_token_updates_bearer_without_id_token() {
2088 let current = sample_session();
2089 let resp = OpenAIRefreshResponse {
2090 id_token: None,
2091 access_token: Some("replaced-access".to_string()),
2092 refresh_token: None,
2093 expires_in: Some(7200),
2094 };
2095 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2096 assert_eq!(merged.access_token, "replaced-access");
2097 assert_eq!(active_api_bearer_token(&merged), current.openai_api_key);
2100 }
2101
2102 #[tokio::test]
2108 async fn merge_treats_blank_access_token_as_omitted() {
2109 let current = sample_session();
2110 let resp = OpenAIRefreshResponse {
2111 id_token: None,
2112 access_token: Some(" ".to_string()),
2113 refresh_token: Some("new-refresh".to_string()),
2114 expires_in: Some(3600),
2115 };
2116 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2117 assert_eq!(merged.access_token, current.access_token, "blank access_token should be treated as omitted");
2118 assert_eq!(merged.refresh_token, "new-refresh");
2119 }
2120
2121 #[tokio::test]
2122 async fn merge_treats_blank_refresh_token_as_omitted() {
2123 let current = sample_session();
2124 let resp = OpenAIRefreshResponse {
2125 id_token: None,
2126 access_token: Some("new-access".to_string()),
2127 refresh_token: Some(String::new()),
2128 expires_in: None,
2129 };
2130 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2131 assert_eq!(merged.refresh_token, current.refresh_token, "blank refresh_token should be treated as omitted");
2132 assert_eq!(merged.access_token, "new-access");
2133 }
2134
2135 #[tokio::test]
2136 async fn merge_treats_blank_id_token_as_omitted() {
2137 let current = sample_session();
2138 let resp = OpenAIRefreshResponse {
2139 id_token: Some(" ".to_string()),
2140 access_token: Some("new-access".to_string()),
2141 refresh_token: None,
2142 expires_in: None,
2143 };
2144 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2145 assert_eq!(merged.id_token, current.id_token, "blank id_token should be treated as omitted");
2147 assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key preserved when id_token is blank");
2148 }
2149
2150 #[tokio::test]
2153 async fn merge_changed_opaque_access_token_clears_stale_expiry() {
2154 let mut current = sample_session();
2155 current.expires_at = Some(now_secs() + 3600); let resp = OpenAIRefreshResponse {
2160 id_token: None,
2161 access_token: Some("opaque-new-token".to_string()),
2162 refresh_token: None,
2163 expires_in: None,
2164 };
2165 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2166 assert_eq!(merged.access_token, "opaque-new-token");
2167 assert_eq!(merged.expires_at, None, "changed opaque access token must not inherit old token's expiry");
2170 }
2171
2172 #[tokio::test]
2173 async fn merge_repeated_opaque_access_token_preserves_expiry() {
2174 let old_expiry = now_secs() + 3600;
2175 let mut current = sample_session();
2176 current.access_token = "opaque-same-token".to_string();
2177 current.expires_at = Some(old_expiry);
2178
2179 let resp = OpenAIRefreshResponse {
2182 id_token: None,
2183 access_token: Some("opaque-same-token".to_string()),
2184 refresh_token: Some("new-refresh".to_string()),
2185 expires_in: None,
2186 };
2187 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2188 assert_eq!(merged.access_token, "opaque-same-token");
2189 assert_eq!(merged.expires_at, Some(old_expiry), "repeated same opaque access token must preserve old expiry");
2190 }
2191
2192 #[tokio::test]
2193 async fn merge_omitted_access_token_preserves_old_expiry() {
2194 let old_expiry = now_secs() + 3600;
2195 let mut current = sample_session();
2196 current.expires_at = Some(old_expiry);
2197
2198 let resp = OpenAIRefreshResponse {
2199 id_token: None,
2200 access_token: None,
2201 refresh_token: Some("new-refresh".to_string()),
2202 expires_in: None,
2203 };
2204 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2205 assert_eq!(merged.expires_at, Some(old_expiry), "omitted access_token should preserve old expiry");
2207 }
2208
2209 #[tokio::test]
2210 async fn merge_expires_in_overrides_for_omitted_access_token() {
2211 let old_expiry = now_secs() + 3600;
2212 let mut current = sample_session();
2213 current.expires_at = Some(old_expiry);
2214
2215 let resp = OpenAIRefreshResponse {
2216 id_token: None,
2217 access_token: None,
2218 refresh_token: None,
2219 expires_in: Some(1800),
2220 };
2221 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2222 assert_ne!(merged.expires_at, Some(old_expiry));
2224 assert!(merged.expires_at.is_some());
2225 }
2226
2227 #[test]
2230 fn extract_error_code_flat_form() {
2231 assert_eq!(extract_error_code(r#"{"error": "invalid_grant"}"#), "invalid_grant");
2232 }
2233
2234 #[test]
2235 fn extract_error_code_nested_form() {
2236 let body = r#"{"error": {"code": "refresh_token_expired", "message": "token expired"}}"#;
2237 assert_eq!(extract_error_code(body), "refresh_token_expired");
2238 }
2239
2240 #[test]
2241 fn extract_error_code_nested_form_does_not_fall_back_to_message() {
2242 let body = r#"{"error": {"message": "invalid_grant"}}"#;
2246 assert_eq!(extract_error_code(body), "", "message field should not be used as error code: {body}");
2247 }
2248
2249 #[test]
2250 fn extract_error_code_empty_body() {
2251 assert_eq!(extract_error_code(""), "");
2252 }
2253
2254 #[test]
2255 fn extract_error_code_non_json_body() {
2256 assert_eq!(extract_error_code("Internal Server Error"), "");
2257 }
2258
2259 #[test]
2260 fn extract_error_code_blank_error_field() {
2261 assert_eq!(extract_error_code(r#"{"error": " "}"#), "");
2262 }
2263
2264 #[test]
2265 fn classify_refresh_status_error_invalid_grant_clears_session() {
2266 let _guard = TestAuthDirGuard::new();
2267 let session = sample_session();
2269 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2270 assert!(
2271 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2272 .expect("load")
2273 .is_some()
2274 );
2275
2276 let err = classify_refresh_status_error(reqwest::StatusCode::BAD_REQUEST, r#"{"error": "invalid_grant"}"#);
2277 assert!(err.to_string().contains("session expired"));
2278
2279 assert!(
2281 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2282 .expect("load")
2283 .is_none()
2284 );
2285 }
2286
2287 #[test]
2288 fn classify_refresh_status_error_nested_refresh_token_expired_clears_session() {
2289 let _guard = TestAuthDirGuard::new();
2290 let session = sample_session();
2291 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2292
2293 let err = classify_refresh_status_error(
2294 reqwest::StatusCode::BAD_REQUEST,
2295 r#"{"error": {"code": "refresh_token_expired", "message": "The refresh token has expired"}}"#,
2296 );
2297 assert!(err.to_string().contains("session expired"));
2298
2299 assert!(
2300 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2301 .expect("load")
2302 .is_none()
2303 );
2304 }
2305
2306 #[test]
2307 fn classify_refresh_status_error_unauthorized_preserves_session() {
2308 let _guard = TestAuthDirGuard::new();
2309 let session = sample_session();
2310 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2311
2312 let err = classify_refresh_status_error(reqwest::StatusCode::UNAUTHORIZED, "");
2313 assert!(err.to_string().contains("HTTP 401"), "should report HTTP 401: {err}");
2314 assert!(
2316 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2317 .expect("load")
2318 .is_some(),
2319 "session should be preserved on ambiguous 401"
2320 );
2321 }
2322
2323 #[test]
2324 fn classify_refresh_status_error_server_error_does_not_clear_session() {
2325 let _guard = TestAuthDirGuard::new();
2326 let session = sample_session();
2327 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2328
2329 let err =
2330 classify_refresh_status_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, r#"{"error": "internal_error"}"#);
2331 assert!(err.to_string().contains("HTTP 500"));
2332 assert!(
2334 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2335 .expect("load")
2336 .is_some()
2337 );
2338 }
2339
2340 #[test]
2341 fn classify_refresh_status_error_never_includes_raw_body() {
2342 let _guard = TestAuthDirGuard::new();
2343 let err = classify_refresh_status_error(
2344 reqwest::StatusCode::BAD_REQUEST,
2345 r#"{"error": "invalid_grant", "sensitive_data": "secret-leak-attempt"}"#,
2346 );
2347 assert!(!err.to_string().contains("secret-leak-attempt"), "raw body leaked into error: {err}");
2348 }
2349
2350 #[derive(Debug, PartialEq)]
2354 enum ClassifyOutcome {
2355 Terminal,
2357 Preserved(&'static str),
2359 }
2360
2361 fn run_classify_matrix(status: reqwest::StatusCode, body: &str, expected: ClassifyOutcome) {
2362 let _guard = TestAuthDirGuard::new();
2363 let session = sample_session();
2364 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2365
2366 let err = classify_refresh_status_error(status, body);
2367 let msg = err.to_string();
2368 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("load");
2369
2370 match expected {
2371 ClassifyOutcome::Terminal => {
2372 assert!(msg.contains("session expired"), "terminal error should say 'session expired': {msg}");
2373 assert!(loaded.is_none(), "session should be cleared for terminal: {status} {body}");
2374 }
2375 ClassifyOutcome::Preserved(frag) => {
2376 assert!(!msg.contains("session expired"), "non-terminal should not say 'session expired': {msg}");
2377 assert!(loaded.is_some(), "session should be preserved for: {status} {body}");
2378 if !frag.is_empty() {
2379 assert!(msg.contains(frag), "error should contain '{frag}': {msg}");
2380 }
2381 }
2382 }
2383 }
2384
2385 #[test]
2386 fn classify_matrix_terminal_400_flat() {
2387 run_classify_matrix(
2388 reqwest::StatusCode::BAD_REQUEST,
2389 r#"{"error": "invalid_grant"}"#,
2390 ClassifyOutcome::Terminal,
2391 );
2392 }
2393
2394 #[test]
2395 fn classify_matrix_terminal_400_nested_code() {
2396 run_classify_matrix(
2397 reqwest::StatusCode::BAD_REQUEST,
2398 r#"{"error": {"code": "invalid_grant", "message": "..."}}"#,
2399 ClassifyOutcome::Terminal,
2400 );
2401 }
2402
2403 #[test]
2404 fn classify_matrix_terminal_400_nested_type() {
2405 run_classify_matrix(
2406 reqwest::StatusCode::BAD_REQUEST,
2407 r#"{"error": {"type": "invalid_grant", "message": "..."}}"#,
2408 ClassifyOutcome::Terminal,
2409 );
2410 }
2411
2412 #[test]
2413 fn classify_matrix_terminal_400_toplevel_code() {
2414 run_classify_matrix(
2415 reqwest::StatusCode::BAD_REQUEST,
2416 r#"{"code": "refresh_token_revoked", "message": "..."}"#,
2417 ClassifyOutcome::Terminal,
2418 );
2419 }
2420
2421 #[test]
2422 fn classify_matrix_terminal_401_flat() {
2423 run_classify_matrix(
2425 reqwest::StatusCode::UNAUTHORIZED,
2426 r#"{"error": "invalid_token"}"#,
2427 ClassifyOutcome::Terminal,
2428 );
2429 }
2430
2431 #[test]
2432 fn classify_matrix_terminal_401_nested() {
2433 run_classify_matrix(
2434 reqwest::StatusCode::UNAUTHORIZED,
2435 r#"{"error": {"code": "refresh_token_expired"}}"#,
2436 ClassifyOutcome::Terminal,
2437 );
2438 }
2439
2440 #[test]
2441 fn classify_matrix_terminal_401_refresh_token_invalidated() {
2442 run_classify_matrix(
2443 reqwest::StatusCode::UNAUTHORIZED,
2444 r#"{"error": "refresh_token_invalidated"}"#,
2445 ClassifyOutcome::Terminal,
2446 );
2447 }
2448
2449 #[test]
2450 fn classify_matrix_terminal_400_toplevel_refresh_token_reused() {
2451 run_classify_matrix(
2452 reqwest::StatusCode::BAD_REQUEST,
2453 r#"{"code": "refresh_token_reused", "message": "..."}"#,
2454 ClassifyOutcome::Terminal,
2455 );
2456 }
2457
2458 #[test]
2459 fn classify_matrix_message_only_does_not_clear_session() {
2460 run_classify_matrix(
2465 reqwest::StatusCode::BAD_REQUEST,
2466 r#"{"error": {"message": "invalid_grant"}}"#,
2467 ClassifyOutcome::Preserved("HTTP 400"),
2468 );
2469 }
2470
2471 #[test]
2472 fn classify_matrix_invalid_client_preserves() {
2473 run_classify_matrix(
2474 reqwest::StatusCode::BAD_REQUEST,
2475 r#"{"error": "invalid_client"}"#,
2476 ClassifyOutcome::Preserved("invalid_client"),
2477 );
2478 }
2479
2480 #[test]
2481 fn classify_matrix_invalid_client_401_preserves() {
2482 run_classify_matrix(
2483 reqwest::StatusCode::UNAUTHORIZED,
2484 r#"{"error": "invalid_client"}"#,
2485 ClassifyOutcome::Preserved("invalid_client"),
2486 );
2487 }
2488
2489 #[test]
2490 fn classify_matrix_429_throttling_preserves() {
2491 run_classify_matrix(
2492 reqwest::StatusCode::TOO_MANY_REQUESTS,
2493 r#"{"error": "rate_limited"}"#,
2494 ClassifyOutcome::Preserved("rate-limited"),
2495 );
2496 }
2497
2498 #[test]
2499 fn classify_matrix_500_server_error_preserves() {
2500 run_classify_matrix(
2501 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2502 r#"{"error": "internal_error"}"#,
2503 ClassifyOutcome::Preserved("HTTP 500"),
2504 );
2505 }
2506
2507 #[test]
2508 fn classify_matrix_502_bad_gateway_preserves() {
2509 run_classify_matrix(reqwest::StatusCode::BAD_GATEWAY, "", ClassifyOutcome::Preserved("HTTP 502"));
2510 }
2511
2512 #[test]
2513 fn classify_matrix_503_service_unavailable_preserves() {
2514 run_classify_matrix(reqwest::StatusCode::SERVICE_UNAVAILABLE, "", ClassifyOutcome::Preserved("HTTP 503"));
2515 }
2516
2517 #[test]
2518 fn classify_matrix_ambiguous_401_empty_body_preserves() {
2519 run_classify_matrix(reqwest::StatusCode::UNAUTHORIZED, "", ClassifyOutcome::Preserved("HTTP 401"));
2520 }
2521
2522 #[test]
2523 fn classify_matrix_400_nonterminal_code_preserves() {
2524 run_classify_matrix(
2526 reqwest::StatusCode::BAD_REQUEST,
2527 r#"{"error": "some_unknown_error"}"#,
2528 ClassifyOutcome::Preserved("HTTP 400"),
2529 );
2530 }
2531
2532 #[test]
2533 fn classify_matrix_never_leaks_body_in_any_branch() {
2534 let _guard = TestAuthDirGuard::new();
2536 let err = classify_refresh_status_error(
2537 reqwest::StatusCode::BAD_REQUEST,
2538 r#"{"error": "invalid_grant", "leak": "TERMINAL_LEAK"}"#,
2539 );
2540 assert!(!err.to_string().contains("TERMINAL_LEAK"));
2541
2542 let err = classify_refresh_status_error(
2544 reqwest::StatusCode::BAD_REQUEST,
2545 r#"{"error": "invalid_client", "leak": "CLIENT_LEAK"}"#,
2546 );
2547 assert!(!err.to_string().contains("CLIENT_LEAK"));
2548
2549 let err = classify_refresh_status_error(
2551 reqwest::StatusCode::TOO_MANY_REQUESTS,
2552 r#"{"error": "rate_limited", "leak": "THROTTLE_LEAK"}"#,
2553 );
2554 assert!(!err.to_string().contains("THROTTLE_LEAK"));
2555 }
2556
2557 #[test]
2560 fn extract_error_code_nested_with_type_field() {
2561 let body = r#"{"error": {"type": "invalid_grant", "message": "..."}}"#;
2562 assert_eq!(extract_error_code(body), "invalid_grant");
2563 }
2564
2565 #[test]
2566 fn extract_error_code_toplevel_code_field() {
2567 let body = r#"{"code": "refresh_token_expired", "message": "..."}"#;
2568 assert_eq!(extract_error_code(body), "refresh_token_expired");
2569 }
2570
2571 #[test]
2572 fn extract_error_code_case_insensitive_normalization() {
2573 assert_eq!(extract_error_code(r#"{"error": "INVALID_GRANT"}"#), "invalid_grant");
2574 }
2575
2576 #[test]
2577 fn extract_error_code_no_substring_matching() {
2578 let code = extract_error_code(r#"{"error": "invalid_grant_really_not_a_real_code"}"#);
2581 assert_eq!(code, "invalid_grant_really_not_a_real_code");
2582 let _guard = TestAuthDirGuard::new();
2584 let session = sample_session();
2585 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2586 let err = classify_refresh_status_error(
2587 reqwest::StatusCode::BAD_REQUEST,
2588 r#"{"error": "invalid_grant_really_not_a_real_code"}"#,
2589 );
2590 assert!(!err.to_string().contains("session expired"));
2591 assert!(
2592 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2593 .expect("load")
2594 .is_some()
2595 );
2596 }
2597
2598 #[test]
2601 fn pkce_challenge_debug_redacts_verifier() {
2602 let challenge = generate_pkce_challenge().expect("generate pkce");
2603 let debug_str = format!("{challenge:?}");
2604 assert!(!debug_str.contains(&challenge.code_verifier), "code_verifier leaked: {debug_str}");
2605 assert!(debug_str.contains("<redacted>"), "verifier should be redacted: {debug_str}");
2606 assert!(debug_str.contains(&challenge.code_challenge), "code_challenge should be visible: {debug_str}");
2608 }
2609
2610 #[test]
2611 fn auth_callback_outcome_debug_redacts_code() {
2612 let outcome = AuthCallbackOutcome::Code("super-secret-auth-code".to_string());
2613 let debug_str = format!("{outcome:?}");
2614 assert!(!debug_str.contains("super-secret-auth-code"), "authorization code leaked: {debug_str}");
2615 assert!(debug_str.contains("<redacted>"), "code should be redacted: {debug_str}");
2616 }
2617
2618 #[test]
2619 fn auth_callback_outcome_debug_shows_cancelled_and_redacts_error() {
2620 let cancelled = format!("{:?}", AuthCallbackOutcome::Cancelled);
2621 assert!(cancelled.contains("Cancelled"));
2622
2623 let error = format!("{:?}", AuthCallbackOutcome::Error("access_denied".to_string()));
2626 assert!(!error.contains("access_denied"), "error message leaked through Debug: {error}");
2627 assert!(error.contains("<redacted>"), "error should be redacted: {error}");
2628 }
2629}