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 drop(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.get(..remaining).unwrap_or_default());
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
1371 .as_ref()
1372 .get(..32)
1373 .context("openai session encryption key was too short")?
1374 .try_into()
1375 .context("openai session encryption key had an invalid length")?;
1376 let unbound =
1377 UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("invalid openai session encryption key"))?;
1378 Ok(LessSafeKey::new(unbound))
1379}
1380
1381fn now_secs() -> u64 {
1382 std::time::SystemTime::now()
1383 .duration_since(std::time::UNIX_EPOCH)
1384 .map(|duration| duration.as_secs())
1385 .unwrap_or(0)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use crate::AuthCallbackOutcome;
1392 use crate::generate_pkce_challenge;
1393 use assert_fs::TempDir;
1394 use serial_test::serial;
1395 use std::sync::Arc;
1396
1397 struct ExternalRefresher;
1398
1399 #[async_trait]
1400 impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1401 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1402 let mut refreshed = current.clone();
1403 refreshed.access_token = "oauth-access-refreshed".to_string();
1404 refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1405 refreshed.expires_at = Some(now_secs() + 3600);
1406 Ok(refreshed)
1407 }
1408 }
1409
1410 struct TestAuthDirGuard {
1411 temp_dir: Option<TempDir>,
1412 codex_temp_dir: Option<TempDir>,
1413 previous: Option<PathBuf>,
1414 previous_codex_home: Option<String>,
1415 }
1416
1417 impl TestAuthDirGuard {
1418 fn new() -> Self {
1419 let temp_dir = TempDir::new().expect("create temp auth dir");
1420 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
1421 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
1422 .expect("set temp auth dir override");
1423
1424 let codex_temp_dir = TempDir::new().expect("create temp codex home");
1427 let previous_codex_home = std::env::var("CODEX_HOME").ok();
1428 vtcode_commons::env_lock::set_var("CODEX_HOME", codex_temp_dir.path());
1429
1430 Self {
1431 temp_dir: Some(temp_dir),
1432 codex_temp_dir: Some(codex_temp_dir),
1433 previous,
1434 previous_codex_home,
1435 }
1436 }
1437 }
1438
1439 impl Drop for TestAuthDirGuard {
1440 fn drop(&mut self) {
1441 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1442 .expect("restore auth dir override");
1443 if let Some(temp_dir) = self.temp_dir.take() {
1444 temp_dir.close().expect("remove temp auth dir");
1445 }
1446 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", self.previous_codex_home.as_deref());
1447 if let Some(codex_temp_dir) = self.codex_temp_dir.take() {
1448 codex_temp_dir.close().expect("remove temp codex home");
1449 }
1450 }
1451 }
1452
1453 fn sample_session() -> OpenAIChatGptSession {
1454 OpenAIChatGptSession {
1455 openai_api_key: "api-key".to_string(),
1456 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1457 access_token: "oauth-access".to_string(),
1458 refresh_token: "refresh-token".to_string(),
1459 account_id: Some("acc_123".to_string()),
1460 email: Some("test@example.com".to_string()),
1461 plan: Some("plus".to_string()),
1462 obtained_at: 10,
1463 refreshed_at: 10,
1464 expires_at: Some(now_secs() + 3600),
1465 }
1466 }
1467
1468 #[test]
1469 fn auth_url_contains_expected_openai_parameters() {
1470 let env = OauthEnvGuard::new();
1472 env.remove_client_id();
1473 env.remove_originator();
1474
1475 let challenge = PkceChallenge {
1476 code_verifier: "verifier".to_string(),
1477 code_challenge: "challenge".to_string(),
1478 code_challenge_method: "S256".to_string(),
1479 };
1480
1481 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1482 assert!(url.starts_with(OPENAI_AUTH_URL));
1483 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1484 assert!(url.contains("code_challenge=challenge"));
1485 assert!(url.contains("codex_cli_simplified_flow=true"));
1486 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1487 assert!(url.contains("state=test-state"));
1488 }
1489
1490 #[test]
1491 fn auth_url_honors_custom_client_id_env_override() {
1492 let env = OauthEnvGuard::new();
1494 env.set_client_id("app_custom_override");
1495 env.set_originator("vtcode_custom");
1496
1497 let challenge = PkceChallenge {
1498 code_verifier: "verifier".to_string(),
1499 code_challenge: "challenge".to_string(),
1500 code_challenge_method: "S256".to_string(),
1501 };
1502 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1503 assert!(url.contains("client_id=app_custom_override"), "custom client_id not used: {url}");
1504 assert!(url.contains("originator=vtcode_custom"), "custom originator not used: {url}");
1505 assert!(!url.contains("app_EMoamEEZ73f0CkXaXp7hrann"), "default client_id leaked through override: {url}");
1506 }
1507
1508 struct OauthEnvGuard {
1512 env: vtcode_commons::env_lock::EnvGuard,
1513 prev_client_id: Option<std::ffi::OsString>,
1514 prev_originator: Option<std::ffi::OsString>,
1515 }
1516
1517 impl OauthEnvGuard {
1518 fn new() -> Self {
1519 let env = vtcode_commons::env_lock::lock();
1520 let prev_client_id = std::env::var_os("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1521 let prev_originator = std::env::var_os("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1522 Self { env, prev_client_id, prev_originator }
1523 }
1524
1525 fn set_client_id(&self, value: &str) {
1526 self.env.set_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", value);
1527 }
1528
1529 fn set_originator(&self, value: &str) {
1530 self.env.set_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", value);
1531 }
1532
1533 fn remove_client_id(&self) {
1534 self.env.remove_var("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1535 }
1536
1537 fn remove_originator(&self) {
1538 self.env.remove_var("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1539 }
1540 }
1541
1542 impl Drop for OauthEnvGuard {
1543 fn drop(&mut self) {
1544 self.env
1545 .restore_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", self.prev_client_id.take());
1546 self.env
1547 .restore_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", self.prev_originator.take());
1548 }
1549 }
1550
1551 #[test]
1552 fn resolve_oauth_client_identity_both_defaults() {
1553 let env = OauthEnvGuard::new();
1554 env.remove_client_id();
1555 env.remove_originator();
1556
1557 let identity = resolve_oauth_client_identity().expect("defaults");
1558 assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1559 assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1560 }
1561
1562 #[test]
1563 fn resolve_oauth_client_identity_both_custom() {
1564 let env = OauthEnvGuard::new();
1565 env.set_client_id("app_custom");
1566 env.set_originator("my_originator");
1567
1568 let identity = resolve_oauth_client_identity().expect("custom pair");
1569 assert_eq!(identity.client_id, "app_custom");
1570 assert_eq!(identity.originator, "my_originator");
1571 }
1572
1573 #[test]
1574 fn resolve_oauth_client_identity_only_client_id_is_error() {
1575 let env = OauthEnvGuard::new();
1576 env.set_client_id("app_custom");
1577 env.remove_originator();
1578
1579 let err = resolve_oauth_client_identity().unwrap_err();
1580 let msg = err.to_string();
1581 assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the set var: {msg}");
1582 assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the missing var: {msg}");
1583 }
1584
1585 #[test]
1586 fn resolve_oauth_client_identity_only_originator_is_error() {
1587 let env = OauthEnvGuard::new();
1588 env.remove_client_id();
1589 env.set_originator("my_originator");
1590
1591 let err = resolve_oauth_client_identity().unwrap_err();
1592 let msg = err.to_string();
1593 assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the set var: {msg}");
1594 assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the missing var: {msg}");
1595 }
1596
1597 #[test]
1598 fn resolve_oauth_client_identity_blank_values_treated_as_unset() {
1599 let env = OauthEnvGuard::new();
1600 env.set_client_id(" ");
1601 env.set_originator(" ");
1602
1603 let identity = resolve_oauth_client_identity().expect("defaults from blank");
1605 assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1606 assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1607 }
1608
1609 #[test]
1610 fn parse_jwt_claims_extracts_openai_claims() {
1611 let claims = parse_jwt_claims(
1612 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1613 )
1614 .expect("claims");
1615 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1616 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1617 assert_eq!(claims.plan.as_deref(), Some("plus"));
1618 }
1619
1620 #[test]
1621 fn session_refresh_due_uses_expiry_and_age() {
1622 let mut session = sample_session();
1623 let now = now_secs();
1624 session.obtained_at = now;
1625 session.refreshed_at = now;
1626 session.expires_at = Some(now + 3600);
1627 assert!(!session.is_refresh_due());
1628 session.expires_at = Some(now);
1629 assert!(session.is_refresh_due());
1630 }
1631
1632 #[tokio::test]
1633 #[serial]
1634 async fn external_auth_handle_refreshes_without_persisting_session() {
1635 let _guard = TestAuthDirGuard::new();
1636 let mut session = sample_session();
1637 session.openai_api_key.clear();
1638 session.expires_at = Some(now_secs().saturating_sub(1));
1639 let handle = OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1640
1641 assert!(handle.using_external_tokens());
1642 assert!(
1643 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1644 .expect("load session")
1645 .is_none()
1646 );
1647
1648 handle.force_refresh().await.expect("force refresh");
1649
1650 assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1651 assert!(
1652 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1653 .expect("load session")
1654 .is_none()
1655 );
1656 }
1657
1658 struct CountingExternalRefresher {
1659 calls: Arc<Mutex<usize>>,
1660 }
1661
1662 #[async_trait]
1663 impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1664 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1665 let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1666 *calls += 1;
1667 drop(calls);
1668
1669 let mut refreshed = current.clone();
1670 refreshed.access_token = "oauth-access-refreshed".to_string();
1671 refreshed.refreshed_at = now_secs();
1672 refreshed.expires_at = Some(now_secs() + 3600);
1673 Ok(refreshed)
1674 }
1675 }
1676
1677 #[tokio::test]
1678 async fn refresh_if_needed_serializes_external_refreshes() {
1679 let mut session = sample_session();
1680 session.openai_api_key.clear();
1681 session.expires_at = Some(now_secs().saturating_sub(1));
1682 let calls = Arc::new(Mutex::new(0usize));
1683 let handle = OpenAIChatGptAuthHandle::new_external(
1684 session,
1685 true,
1686 Arc::new(CountingExternalRefresher { calls: Arc::clone(&calls) }),
1687 );
1688
1689 let first = handle.clone();
1690 let second = handle.clone();
1691 let (first_result, second_result) = tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1692
1693 first_result.expect("first refresh should succeed");
1694 second_result.expect("second refresh should succeed");
1695 assert_eq!(
1696 *calls.lock().expect("refresh calls mutex should lock"),
1697 1,
1698 "concurrent refresh_if_needed calls should share one refresh"
1699 );
1700 assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1701 }
1702
1703 #[test]
1704 #[serial]
1705 fn resolve_openai_auth_prefers_chatgpt_in_auto_permission() {
1706 let _guard = TestAuthDirGuard::new();
1707 let session = sample_session();
1708 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1709 let resolved = resolve_openai_auth(
1710 &OpenAIAuthConfig::default(),
1711 AuthCredentialsStoreMode::File,
1712 Some("api-key".to_string()),
1713 )
1714 .expect("resolved auth");
1715 assert!(resolved.using_chatgpt());
1716 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1717 }
1718
1719 #[test]
1720 #[serial]
1721 #[cfg(unix)]
1722 fn file_storage_uses_private_permissions() {
1723 use std::fs;
1724 use std::os::unix::fs::PermissionsExt;
1725
1726 let _guard = TestAuthDirGuard::new();
1727 let session = sample_session();
1728
1729 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1730
1731 let metadata = fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1732 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1733 }
1734
1735 #[test]
1736 #[serial]
1737 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1738 let _guard = TestAuthDirGuard::new();
1739 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1740 let resolved = resolve_openai_auth(
1741 &OpenAIAuthConfig::default(),
1742 AuthCredentialsStoreMode::File,
1743 Some("api-key".to_string()),
1744 )
1745 .expect("resolved auth");
1746 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1747 }
1748
1749 #[test]
1750 #[serial]
1751 fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1752 let _guard = TestAuthDirGuard::new();
1753 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1754 let error =
1755 resolve_openai_auth(&OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File, Some(" ".to_string()))
1756 .expect_err("blank api key should fail");
1757 assert!(error.to_string().contains("OpenAI API key not found"));
1758 }
1759
1760 #[test]
1761 #[serial]
1762 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1763 let _guard = TestAuthDirGuard::new();
1764 let session = sample_session();
1765 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1766 let resolved = resolve_openai_auth(
1767 &OpenAIAuthConfig {
1768 preferred_method: OpenAIPreferredMethod::ApiKey,
1769 ..OpenAIAuthConfig::default()
1770 },
1771 AuthCredentialsStoreMode::File,
1772 Some("api-key".to_string()),
1773 )
1774 .expect("resolved auth");
1775 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1776 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1777 }
1778
1779 #[test]
1780 #[serial]
1781 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1782 let _guard = TestAuthDirGuard::new();
1783 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1784 let error = resolve_openai_auth(
1785 &OpenAIAuthConfig {
1786 preferred_method: OpenAIPreferredMethod::Chatgpt,
1787 ..OpenAIAuthConfig::default()
1788 },
1789 AuthCredentialsStoreMode::File,
1790 Some("api-key".to_string()),
1791 )
1792 .expect_err("chatgpt mode should require a stored session");
1793 assert!(error.to_string().contains("vtcode login openai"));
1794 }
1795
1796 #[test]
1797 #[serial]
1798 fn summarize_openai_credentials_reports_dual_source_notice() {
1799 let _guard = TestAuthDirGuard::new();
1800 let session = sample_session();
1801 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1802 let overview = summarize_openai_credentials(
1803 &OpenAIAuthConfig::default(),
1804 AuthCredentialsStoreMode::File,
1805 Some("api-key".to_string()),
1806 )
1807 .expect("overview");
1808 assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ChatGpt));
1809 assert!(overview.notice.is_some());
1810 assert!(overview.recommendation.is_some());
1811 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1812 }
1813
1814 #[test]
1815 #[serial]
1816 fn summarize_openai_credentials_respects_api_key_preference() {
1817 let _guard = TestAuthDirGuard::new();
1818 let session = sample_session();
1819 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1820 let overview = summarize_openai_credentials(
1821 &OpenAIAuthConfig {
1822 preferred_method: OpenAIPreferredMethod::ApiKey,
1823 ..OpenAIAuthConfig::default()
1824 },
1825 AuthCredentialsStoreMode::File,
1826 Some("api-key".to_string()),
1827 )
1828 .expect("overview");
1829 assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ApiKey));
1830 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1831 }
1832
1833 #[test]
1834 fn encrypted_file_round_trip_restores_session() {
1835 let session = sample_session();
1836 let encrypted = encrypt_session(&session).expect("encrypt");
1837 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1838 assert_eq!(decrypted.account_id, session.account_id);
1839 assert_eq!(decrypted.email, session.email);
1840 assert_eq!(decrypted.plan, session.plan);
1841 }
1842
1843 #[test]
1844 #[serial]
1845 fn default_loader_falls_back_to_file_session() {
1846 let _guard = TestAuthDirGuard::new();
1847 let session = sample_session();
1848 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1849
1850 let loaded = load_openai_chatgpt_session()
1851 .expect("load session")
1852 .expect("stored session should be found");
1853
1854 assert_eq!(loaded.account_id, session.account_id);
1855 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1856 }
1857
1858 #[test]
1859 #[serial]
1860 fn keyring_mode_loader_falls_back_to_file_session() {
1861 let _guard = TestAuthDirGuard::new();
1862 let session = sample_session();
1863 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1864
1865 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1866 .expect("load session")
1867 .expect("stored session should be found");
1868
1869 assert_eq!(loaded.email, session.email);
1870 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1871 }
1872
1873 #[test]
1874 #[serial]
1875 fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1876 let _guard = TestAuthDirGuard::new();
1877 let session = sample_session();
1878 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save file session");
1879
1880 if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring).is_err() {
1881 clear_openai_chatgpt_session().expect("clear session");
1882 assert!(
1883 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1884 .expect("load file session")
1885 .is_none()
1886 );
1887 return;
1888 }
1889
1890 clear_openai_chatgpt_session().expect("clear session");
1891 assert!(
1892 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1893 .expect("load file session")
1894 .is_none()
1895 );
1896 assert!(
1897 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1898 .expect("load keyring session")
1899 .is_none()
1900 );
1901 }
1902
1903 #[test]
1904 fn active_api_bearer_token_falls_back_to_access_token() {
1905 let mut session = sample_session();
1906 session.openai_api_key.clear();
1907
1908 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1909 }
1910
1911 #[test]
1912 fn parse_manual_callback_input_accepts_full_redirect_url() {
1913 let code = parse_openai_chatgpt_manual_callback_input(
1914 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1915 "test-state",
1916 )
1917 .expect("manual input should parse");
1918 assert_eq!(code, "auth-code");
1919 }
1920
1921 #[test]
1922 fn parse_manual_callback_input_accepts_query_string() {
1923 let code = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=test-state", "test-state")
1924 .expect("manual input should parse");
1925 assert_eq!(code, "auth-code");
1926 }
1927
1928 #[test]
1929 fn parse_manual_callback_input_rejects_bare_code() {
1930 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1931 .expect_err("bare code should be rejected");
1932 assert!(error.to_string().contains("full redirect url or query string"));
1933 }
1934
1935 #[test]
1936 fn parse_manual_callback_input_rejects_state_mismatch() {
1937 let error = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=wrong-state", "test-state")
1938 .expect_err("state mismatch should fail");
1939 assert!(error.to_string().contains("state mismatch"));
1940 }
1941
1942 #[tokio::test]
1943 #[serial]
1944 async fn refresh_lock_serializes_parallel_acquisition() {
1945 let _guard = TestAuthDirGuard::new();
1946 let first = tokio::spawn(async {
1947 let _lock = acquire_refresh_lock().await.expect("first lock");
1948 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1949 });
1950 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1951
1952 let start = std::time::Instant::now();
1953 let second = tokio::spawn(async {
1954 let _lock = acquire_refresh_lock().await.expect("second lock");
1955 });
1956
1957 first.await.expect("first task");
1958 second.await.expect("second task");
1959 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1960 }
1961
1962 #[test]
1965 fn debug_impl_redacts_all_token_fields() {
1966 let session = sample_session();
1967 let debug_str = format!("{session:?}");
1968 assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
1970 assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
1971 assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
1972 assert!(!debug_str.contains("eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20i"), "id_token leaked: {debug_str}");
1974 assert!(debug_str.contains("test@example.com"), "email should be visible: {debug_str}");
1976 assert!(debug_str.contains("plus"), "plan should be visible: {debug_str}");
1977 }
1978
1979 #[test]
1980 fn debug_impl_redacts_resolved_auth_api_key() {
1981 let resolved = OpenAIResolvedAuth::ApiKey { api_key: "sk-secret-key".to_string() };
1982 let debug_str = format!("{resolved:?}");
1983 assert!(!debug_str.contains("sk-secret-key"), "api_key leaked: {debug_str}");
1984 }
1985
1986 #[test]
1987 fn debug_impl_redacts_resolved_auth_chatgpt_handle() {
1988 let _guard = TestAuthDirGuard::new();
1989 let session = sample_session();
1990 let handle = OpenAIChatGptAuthHandle::new(session, OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File);
1991 let resolved = OpenAIResolvedAuth::ChatGpt { api_key: "sk-secret-bearer".to_string(), handle };
1992 let debug_str = format!("{resolved:?}");
1993 assert!(!debug_str.contains("sk-secret-bearer"), "bearer leaked: {debug_str}");
1994 }
1995
1996 #[test]
1997 fn credential_overview_carries_no_token_fields() {
1998 let _guard = TestAuthDirGuard::new();
1999 let session = sample_session();
2000 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2001 let overview = summarize_openai_credentials(
2002 &OpenAIAuthConfig::default(),
2003 AuthCredentialsStoreMode::File,
2004 Some("sk-overview-key".to_string()),
2005 )
2006 .expect("overview");
2007 let debug_str = format!("{overview:?}");
2010 assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
2011 assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
2012 assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
2013 assert!(!debug_str.contains("sk-overview-key"), "api key value leaked: {debug_str}");
2015 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear");
2016 }
2017
2018 #[tokio::test]
2025 async fn merge_preserves_omitted_access_token() {
2026 let current = sample_session();
2027 let resp = OpenAIRefreshResponse {
2028 id_token: None,
2029 access_token: None,
2030 refresh_token: Some("new-refresh".to_string()),
2031 expires_in: Some(3600),
2032 };
2033 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2034 assert_eq!(merged.access_token, current.access_token, "omitted access_token should be preserved");
2035 assert_eq!(merged.refresh_token, "new-refresh");
2036 }
2037
2038 #[tokio::test]
2039 async fn merge_preserves_omitted_refresh_token() {
2040 let current = sample_session();
2041 let resp = OpenAIRefreshResponse {
2042 id_token: None,
2043 access_token: Some("new-access".to_string()),
2044 refresh_token: None,
2045 expires_in: None,
2046 };
2047 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2048 assert_eq!(merged.refresh_token, current.refresh_token, "omitted refresh_token should be preserved");
2049 assert_eq!(merged.access_token, "new-access");
2050 }
2051
2052 #[tokio::test]
2053 async fn merge_preserves_omitted_id_token_and_api_key() {
2054 let current = sample_session();
2055 let resp = OpenAIRefreshResponse {
2056 id_token: None,
2057 access_token: Some("new-access".to_string()),
2058 refresh_token: None,
2059 expires_in: Some(1800),
2060 };
2061 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2062 assert_eq!(merged.id_token, current.id_token, "omitted id_token should be preserved");
2064 assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key should be preserved without new id_token");
2065 assert_eq!(merged.email, current.email);
2067 assert_eq!(merged.plan, current.plan);
2068 }
2069
2070 #[tokio::test]
2071 async fn merge_all_omitted_preserves_everything() {
2072 let current = sample_session();
2073 let resp = OpenAIRefreshResponse {
2074 id_token: None,
2075 access_token: None,
2076 refresh_token: None,
2077 expires_in: None,
2078 };
2079 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2080 assert_eq!(merged.id_token, current.id_token);
2081 assert_eq!(merged.access_token, current.access_token);
2082 assert_eq!(merged.refresh_token, current.refresh_token);
2083 assert_eq!(merged.openai_api_key, current.openai_api_key);
2084 assert_eq!(merged.email, current.email);
2085 assert_eq!(merged.expires_at, current.expires_at);
2087 }
2088
2089 #[tokio::test]
2090 async fn merge_new_access_token_updates_bearer_without_id_token() {
2091 let current = sample_session();
2092 let resp = OpenAIRefreshResponse {
2093 id_token: None,
2094 access_token: Some("replaced-access".to_string()),
2095 refresh_token: None,
2096 expires_in: Some(7200),
2097 };
2098 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2099 assert_eq!(merged.access_token, "replaced-access");
2100 assert_eq!(active_api_bearer_token(&merged), current.openai_api_key);
2103 }
2104
2105 #[tokio::test]
2111 async fn merge_treats_blank_access_token_as_omitted() {
2112 let current = sample_session();
2113 let resp = OpenAIRefreshResponse {
2114 id_token: None,
2115 access_token: Some(" ".to_string()),
2116 refresh_token: Some("new-refresh".to_string()),
2117 expires_in: Some(3600),
2118 };
2119 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2120 assert_eq!(merged.access_token, current.access_token, "blank access_token should be treated as omitted");
2121 assert_eq!(merged.refresh_token, "new-refresh");
2122 }
2123
2124 #[tokio::test]
2125 async fn merge_treats_blank_refresh_token_as_omitted() {
2126 let current = sample_session();
2127 let resp = OpenAIRefreshResponse {
2128 id_token: None,
2129 access_token: Some("new-access".to_string()),
2130 refresh_token: Some(String::new()),
2131 expires_in: None,
2132 };
2133 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2134 assert_eq!(merged.refresh_token, current.refresh_token, "blank refresh_token should be treated as omitted");
2135 assert_eq!(merged.access_token, "new-access");
2136 }
2137
2138 #[tokio::test]
2139 async fn merge_treats_blank_id_token_as_omitted() {
2140 let current = sample_session();
2141 let resp = OpenAIRefreshResponse {
2142 id_token: Some(" ".to_string()),
2143 access_token: Some("new-access".to_string()),
2144 refresh_token: None,
2145 expires_in: None,
2146 };
2147 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2148 assert_eq!(merged.id_token, current.id_token, "blank id_token should be treated as omitted");
2150 assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key preserved when id_token is blank");
2151 }
2152
2153 #[tokio::test]
2156 async fn merge_changed_opaque_access_token_clears_stale_expiry() {
2157 let mut current = sample_session();
2158 current.expires_at = Some(now_secs() + 3600); let resp = OpenAIRefreshResponse {
2163 id_token: None,
2164 access_token: Some("opaque-new-token".to_string()),
2165 refresh_token: None,
2166 expires_in: None,
2167 };
2168 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2169 assert_eq!(merged.access_token, "opaque-new-token");
2170 assert_eq!(merged.expires_at, None, "changed opaque access token must not inherit old token's expiry");
2173 }
2174
2175 #[tokio::test]
2176 async fn merge_repeated_opaque_access_token_preserves_expiry() {
2177 let old_expiry = now_secs() + 3600;
2178 let mut current = sample_session();
2179 current.access_token = "opaque-same-token".to_string();
2180 current.expires_at = Some(old_expiry);
2181
2182 let resp = OpenAIRefreshResponse {
2185 id_token: None,
2186 access_token: Some("opaque-same-token".to_string()),
2187 refresh_token: Some("new-refresh".to_string()),
2188 expires_in: None,
2189 };
2190 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2191 assert_eq!(merged.access_token, "opaque-same-token");
2192 assert_eq!(merged.expires_at, Some(old_expiry), "repeated same opaque access token must preserve old expiry");
2193 }
2194
2195 #[tokio::test]
2196 async fn merge_omitted_access_token_preserves_old_expiry() {
2197 let old_expiry = now_secs() + 3600;
2198 let mut current = sample_session();
2199 current.expires_at = Some(old_expiry);
2200
2201 let resp = OpenAIRefreshResponse {
2202 id_token: None,
2203 access_token: None,
2204 refresh_token: Some("new-refresh".to_string()),
2205 expires_in: None,
2206 };
2207 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2208 assert_eq!(merged.expires_at, Some(old_expiry), "omitted access_token should preserve old expiry");
2210 }
2211
2212 #[tokio::test]
2213 async fn merge_expires_in_overrides_for_omitted_access_token() {
2214 let old_expiry = now_secs() + 3600;
2215 let mut current = sample_session();
2216 current.expires_at = Some(old_expiry);
2217
2218 let resp = OpenAIRefreshResponse {
2219 id_token: None,
2220 access_token: None,
2221 refresh_token: None,
2222 expires_in: Some(1800),
2223 };
2224 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
2225 assert_ne!(merged.expires_at, Some(old_expiry));
2227 assert!(merged.expires_at.is_some());
2228 }
2229
2230 #[test]
2233 fn extract_error_code_flat_form() {
2234 assert_eq!(extract_error_code(r#"{"error": "invalid_grant"}"#), "invalid_grant");
2235 }
2236
2237 #[test]
2238 fn extract_error_code_nested_form() {
2239 let body = r#"{"error": {"code": "refresh_token_expired", "message": "token expired"}}"#;
2240 assert_eq!(extract_error_code(body), "refresh_token_expired");
2241 }
2242
2243 #[test]
2244 fn extract_error_code_nested_form_does_not_fall_back_to_message() {
2245 let body = r#"{"error": {"message": "invalid_grant"}}"#;
2249 assert_eq!(extract_error_code(body), "", "message field should not be used as error code: {body}");
2250 }
2251
2252 #[test]
2253 fn extract_error_code_empty_body() {
2254 assert_eq!(extract_error_code(""), "");
2255 }
2256
2257 #[test]
2258 fn extract_error_code_non_json_body() {
2259 assert_eq!(extract_error_code("Internal Server Error"), "");
2260 }
2261
2262 #[test]
2263 fn extract_error_code_blank_error_field() {
2264 assert_eq!(extract_error_code(r#"{"error": " "}"#), "");
2265 }
2266
2267 #[test]
2268 fn classify_refresh_status_error_invalid_grant_clears_session() {
2269 let _guard = TestAuthDirGuard::new();
2270 let session = sample_session();
2272 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2273 assert!(
2274 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2275 .expect("load")
2276 .is_some()
2277 );
2278
2279 let err = classify_refresh_status_error(reqwest::StatusCode::BAD_REQUEST, r#"{"error": "invalid_grant"}"#);
2280 assert!(err.to_string().contains("session expired"));
2281
2282 assert!(
2284 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2285 .expect("load")
2286 .is_none()
2287 );
2288 }
2289
2290 #[test]
2291 fn classify_refresh_status_error_nested_refresh_token_expired_clears_session() {
2292 let _guard = TestAuthDirGuard::new();
2293 let session = sample_session();
2294 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2295
2296 let err = classify_refresh_status_error(
2297 reqwest::StatusCode::BAD_REQUEST,
2298 r#"{"error": {"code": "refresh_token_expired", "message": "The refresh token has expired"}}"#,
2299 );
2300 assert!(err.to_string().contains("session expired"));
2301
2302 assert!(
2303 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2304 .expect("load")
2305 .is_none()
2306 );
2307 }
2308
2309 #[test]
2310 fn classify_refresh_status_error_unauthorized_preserves_session() {
2311 let _guard = TestAuthDirGuard::new();
2312 let session = sample_session();
2313 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2314
2315 let err = classify_refresh_status_error(reqwest::StatusCode::UNAUTHORIZED, "");
2316 assert!(err.to_string().contains("HTTP 401"), "should report HTTP 401: {err}");
2317 assert!(
2319 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2320 .expect("load")
2321 .is_some(),
2322 "session should be preserved on ambiguous 401"
2323 );
2324 }
2325
2326 #[test]
2327 fn classify_refresh_status_error_server_error_does_not_clear_session() {
2328 let _guard = TestAuthDirGuard::new();
2329 let session = sample_session();
2330 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2331
2332 let err =
2333 classify_refresh_status_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, r#"{"error": "internal_error"}"#);
2334 assert!(err.to_string().contains("HTTP 500"));
2335 assert!(
2337 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2338 .expect("load")
2339 .is_some()
2340 );
2341 }
2342
2343 #[test]
2344 fn classify_refresh_status_error_never_includes_raw_body() {
2345 let _guard = TestAuthDirGuard::new();
2346 let err = classify_refresh_status_error(
2347 reqwest::StatusCode::BAD_REQUEST,
2348 r#"{"error": "invalid_grant", "sensitive_data": "secret-leak-attempt"}"#,
2349 );
2350 assert!(!err.to_string().contains("secret-leak-attempt"), "raw body leaked into error: {err}");
2351 }
2352
2353 #[derive(Debug, PartialEq)]
2357 enum ClassifyOutcome {
2358 Terminal,
2360 Preserved(&'static str),
2362 }
2363
2364 fn run_classify_matrix(status: reqwest::StatusCode, body: &str, expected: ClassifyOutcome) {
2365 let _guard = TestAuthDirGuard::new();
2366 let session = sample_session();
2367 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2368
2369 let err = classify_refresh_status_error(status, body);
2370 let msg = err.to_string();
2371 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("load");
2372
2373 match expected {
2374 ClassifyOutcome::Terminal => {
2375 assert!(msg.contains("session expired"), "terminal error should say 'session expired': {msg}");
2376 assert!(loaded.is_none(), "session should be cleared for terminal: {status} {body}");
2377 }
2378 ClassifyOutcome::Preserved(frag) => {
2379 assert!(!msg.contains("session expired"), "non-terminal should not say 'session expired': {msg}");
2380 assert!(loaded.is_some(), "session should be preserved for: {status} {body}");
2381 if !frag.is_empty() {
2382 assert!(msg.contains(frag), "error should contain '{frag}': {msg}");
2383 }
2384 }
2385 }
2386 }
2387
2388 #[test]
2389 fn classify_matrix_terminal_400_flat() {
2390 run_classify_matrix(
2391 reqwest::StatusCode::BAD_REQUEST,
2392 r#"{"error": "invalid_grant"}"#,
2393 ClassifyOutcome::Terminal,
2394 );
2395 }
2396
2397 #[test]
2398 fn classify_matrix_terminal_400_nested_code() {
2399 run_classify_matrix(
2400 reqwest::StatusCode::BAD_REQUEST,
2401 r#"{"error": {"code": "invalid_grant", "message": "..."}}"#,
2402 ClassifyOutcome::Terminal,
2403 );
2404 }
2405
2406 #[test]
2407 fn classify_matrix_terminal_400_nested_type() {
2408 run_classify_matrix(
2409 reqwest::StatusCode::BAD_REQUEST,
2410 r#"{"error": {"type": "invalid_grant", "message": "..."}}"#,
2411 ClassifyOutcome::Terminal,
2412 );
2413 }
2414
2415 #[test]
2416 fn classify_matrix_terminal_400_toplevel_code() {
2417 run_classify_matrix(
2418 reqwest::StatusCode::BAD_REQUEST,
2419 r#"{"code": "refresh_token_revoked", "message": "..."}"#,
2420 ClassifyOutcome::Terminal,
2421 );
2422 }
2423
2424 #[test]
2425 fn classify_matrix_terminal_401_flat() {
2426 run_classify_matrix(
2428 reqwest::StatusCode::UNAUTHORIZED,
2429 r#"{"error": "invalid_token"}"#,
2430 ClassifyOutcome::Terminal,
2431 );
2432 }
2433
2434 #[test]
2435 fn classify_matrix_terminal_401_nested() {
2436 run_classify_matrix(
2437 reqwest::StatusCode::UNAUTHORIZED,
2438 r#"{"error": {"code": "refresh_token_expired"}}"#,
2439 ClassifyOutcome::Terminal,
2440 );
2441 }
2442
2443 #[test]
2444 fn classify_matrix_terminal_401_refresh_token_invalidated() {
2445 run_classify_matrix(
2446 reqwest::StatusCode::UNAUTHORIZED,
2447 r#"{"error": "refresh_token_invalidated"}"#,
2448 ClassifyOutcome::Terminal,
2449 );
2450 }
2451
2452 #[test]
2453 fn classify_matrix_terminal_400_toplevel_refresh_token_reused() {
2454 run_classify_matrix(
2455 reqwest::StatusCode::BAD_REQUEST,
2456 r#"{"code": "refresh_token_reused", "message": "..."}"#,
2457 ClassifyOutcome::Terminal,
2458 );
2459 }
2460
2461 #[test]
2462 fn classify_matrix_message_only_does_not_clear_session() {
2463 run_classify_matrix(
2468 reqwest::StatusCode::BAD_REQUEST,
2469 r#"{"error": {"message": "invalid_grant"}}"#,
2470 ClassifyOutcome::Preserved("HTTP 400"),
2471 );
2472 }
2473
2474 #[test]
2475 fn classify_matrix_invalid_client_preserves() {
2476 run_classify_matrix(
2477 reqwest::StatusCode::BAD_REQUEST,
2478 r#"{"error": "invalid_client"}"#,
2479 ClassifyOutcome::Preserved("invalid_client"),
2480 );
2481 }
2482
2483 #[test]
2484 fn classify_matrix_invalid_client_401_preserves() {
2485 run_classify_matrix(
2486 reqwest::StatusCode::UNAUTHORIZED,
2487 r#"{"error": "invalid_client"}"#,
2488 ClassifyOutcome::Preserved("invalid_client"),
2489 );
2490 }
2491
2492 #[test]
2493 fn classify_matrix_429_throttling_preserves() {
2494 run_classify_matrix(
2495 reqwest::StatusCode::TOO_MANY_REQUESTS,
2496 r#"{"error": "rate_limited"}"#,
2497 ClassifyOutcome::Preserved("rate-limited"),
2498 );
2499 }
2500
2501 #[test]
2502 fn classify_matrix_500_server_error_preserves() {
2503 run_classify_matrix(
2504 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2505 r#"{"error": "internal_error"}"#,
2506 ClassifyOutcome::Preserved("HTTP 500"),
2507 );
2508 }
2509
2510 #[test]
2511 fn classify_matrix_502_bad_gateway_preserves() {
2512 run_classify_matrix(reqwest::StatusCode::BAD_GATEWAY, "", ClassifyOutcome::Preserved("HTTP 502"));
2513 }
2514
2515 #[test]
2516 fn classify_matrix_503_service_unavailable_preserves() {
2517 run_classify_matrix(reqwest::StatusCode::SERVICE_UNAVAILABLE, "", ClassifyOutcome::Preserved("HTTP 503"));
2518 }
2519
2520 #[test]
2521 fn classify_matrix_ambiguous_401_empty_body_preserves() {
2522 run_classify_matrix(reqwest::StatusCode::UNAUTHORIZED, "", ClassifyOutcome::Preserved("HTTP 401"));
2523 }
2524
2525 #[test]
2526 fn classify_matrix_400_nonterminal_code_preserves() {
2527 run_classify_matrix(
2529 reqwest::StatusCode::BAD_REQUEST,
2530 r#"{"error": "some_unknown_error"}"#,
2531 ClassifyOutcome::Preserved("HTTP 400"),
2532 );
2533 }
2534
2535 #[test]
2536 fn classify_matrix_never_leaks_body_in_any_branch() {
2537 let _guard = TestAuthDirGuard::new();
2539 let err = classify_refresh_status_error(
2540 reqwest::StatusCode::BAD_REQUEST,
2541 r#"{"error": "invalid_grant", "leak": "TERMINAL_LEAK"}"#,
2542 );
2543 assert!(!err.to_string().contains("TERMINAL_LEAK"));
2544
2545 let err = classify_refresh_status_error(
2547 reqwest::StatusCode::BAD_REQUEST,
2548 r#"{"error": "invalid_client", "leak": "CLIENT_LEAK"}"#,
2549 );
2550 assert!(!err.to_string().contains("CLIENT_LEAK"));
2551
2552 let err = classify_refresh_status_error(
2554 reqwest::StatusCode::TOO_MANY_REQUESTS,
2555 r#"{"error": "rate_limited", "leak": "THROTTLE_LEAK"}"#,
2556 );
2557 assert!(!err.to_string().contains("THROTTLE_LEAK"));
2558 }
2559
2560 #[test]
2563 fn extract_error_code_nested_with_type_field() {
2564 let body = r#"{"error": {"type": "invalid_grant", "message": "..."}}"#;
2565 assert_eq!(extract_error_code(body), "invalid_grant");
2566 }
2567
2568 #[test]
2569 fn extract_error_code_toplevel_code_field() {
2570 let body = r#"{"code": "refresh_token_expired", "message": "..."}"#;
2571 assert_eq!(extract_error_code(body), "refresh_token_expired");
2572 }
2573
2574 #[test]
2575 fn extract_error_code_case_insensitive_normalization() {
2576 assert_eq!(extract_error_code(r#"{"error": "INVALID_GRANT"}"#), "invalid_grant");
2577 }
2578
2579 #[test]
2580 fn extract_error_code_no_substring_matching() {
2581 let code = extract_error_code(r#"{"error": "invalid_grant_really_not_a_real_code"}"#);
2584 assert_eq!(code, "invalid_grant_really_not_a_real_code");
2585 let _guard = TestAuthDirGuard::new();
2587 let session = sample_session();
2588 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2589 let err = classify_refresh_status_error(
2590 reqwest::StatusCode::BAD_REQUEST,
2591 r#"{"error": "invalid_grant_really_not_a_real_code"}"#,
2592 );
2593 assert!(!err.to_string().contains("session expired"));
2594 assert!(
2595 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2596 .expect("load")
2597 .is_some()
2598 );
2599 }
2600
2601 #[test]
2604 fn pkce_challenge_debug_redacts_verifier() {
2605 let challenge = generate_pkce_challenge().expect("generate pkce");
2606 let debug_str = format!("{challenge:?}");
2607 assert!(!debug_str.contains(&challenge.code_verifier), "code_verifier leaked: {debug_str}");
2608 assert!(debug_str.contains("<redacted>"), "verifier should be redacted: {debug_str}");
2609 assert!(debug_str.contains(&challenge.code_challenge), "code_challenge should be visible: {debug_str}");
2611 }
2612
2613 #[test]
2614 fn auth_callback_outcome_debug_redacts_code() {
2615 let outcome = AuthCallbackOutcome::Code("super-secret-auth-code".to_string());
2616 let debug_str = format!("{outcome:?}");
2617 assert!(!debug_str.contains("super-secret-auth-code"), "authorization code leaked: {debug_str}");
2618 assert!(debug_str.contains("<redacted>"), "code should be redacted: {debug_str}");
2619 }
2620
2621 #[test]
2622 fn auth_callback_outcome_debug_shows_cancelled_and_redacts_error() {
2623 let cancelled = format!("{:?}", AuthCallbackOutcome::Cancelled);
2624 assert!(cancelled.contains("Cancelled"));
2625
2626 let error = format!("{:?}", AuthCallbackOutcome::Error("access_denied".to_string()));
2629 assert!(!error.contains("access_denied"), "error message leaked through Debug: {error}");
2630 assert!(error.contains("<redacted>"), "error should be redacted: {error}");
2631 }
2632}