1use anyhow::{Context, Result, anyhow, bail};
25use async_trait::async_trait;
26use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
27use fs2::FileExt;
28use reqwest::Client;
29use ring::rand::{SecureRandom, SystemRandom};
30use serde::{Deserialize, Serialize};
31use std::fmt;
32use std::fs;
33use std::fs::OpenOptions;
34use std::sync::{Arc, Mutex};
35use tokio::sync::Mutex as AsyncMutex;
36
37use crate::storage_paths::auth_storage_dir;
38use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
39
40pub use super::credentials::AuthCredentialsStoreMode;
41use super::pkce::PkceChallenge;
42#[cfg(test)]
43use crate::openai_refresh_policy::extract_error_code;
44use crate::openai_refresh_policy::{RefreshFailureAction, classify_refresh_failure};
45use crate::openai_session_storage::OpenAiSessionStorage;
46#[cfg(test)]
47use crate::openai_session_storage::{
48 decrypt_legacy_session as decrypt_session, encrypt_legacy_session as encrypt_session,
49 legacy_session_path as get_session_path,
50};
51
52const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
53const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
54const DEFAULT_OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
70const DEFAULT_OPENAI_ORIGINATOR: &str = "codex_cli_rs";
76const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
80const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
81const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
82const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
83const REFRESH_SKEW_SECS: u64 = 60;
84
85#[derive(Debug)]
97struct OAuthClientIdentity {
98 client_id: String,
99 originator: String,
100}
101
102fn resolve_oauth_client_identity() -> Result<OAuthClientIdentity> {
120 let custom_client_id = std::env::var("VTCODE_OPENAI_OAUTH_CLIENT_ID")
121 .ok()
122 .filter(|v| !v.trim().is_empty());
123 let custom_originator = std::env::var("VTCODE_OPENAI_OAUTH_ORIGINATOR")
124 .ok()
125 .filter(|v| !v.trim().is_empty());
126
127 match (custom_client_id, custom_originator) {
128 (Some(id), Some(originator)) => Ok(OAuthClientIdentity { client_id: id, originator }),
129 (Some(_), None) => bail!(
130 "VTCODE_OPENAI_OAUTH_CLIENT_ID is set but VTCODE_OPENAI_OAUTH_ORIGINATOR is not. \
131 The client ID and originator must be overridden together to form a coherent OAuth \
132 identity. Set VTCODE_OPENAI_OAUTH_ORIGINATOR to match your custom client ID, \
133 or unset VTCODE_OPENAI_OAUTH_CLIENT_ID to use the default Codex identity."
134 ),
135 (None, Some(_)) => bail!(
136 "VTCODE_OPENAI_OAUTH_ORIGINATOR is set but VTCODE_OPENAI_OAUTH_CLIENT_ID is not. \
137 The client ID and originator must be overridden together to form a coherent OAuth \
138 identity. Set VTCODE_OPENAI_OAUTH_CLIENT_ID to match your custom originator, \
139 or unset VTCODE_OPENAI_OAUTH_ORIGINATOR to use the default Codex identity."
140 ),
141 (None, None) => Ok(OAuthClientIdentity {
142 client_id: DEFAULT_OPENAI_CLIENT_ID.to_string(),
143 originator: DEFAULT_OPENAI_ORIGINATOR.to_string(),
144 }),
145 }
146}
147
148#[derive(Clone, Serialize, Deserialize)]
153pub struct OpenAIChatGptSession {
154 pub openai_api_key: String,
157 pub id_token: String,
159 pub access_token: String,
161 pub refresh_token: String,
163 pub account_id: Option<String>,
165 pub email: Option<String>,
167 pub plan: Option<String>,
169 pub obtained_at: u64,
171 pub refreshed_at: u64,
173 pub expires_at: Option<u64>,
175}
176
177impl fmt::Debug for OpenAIChatGptSession {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.debug_struct("OpenAIChatGptSession")
180 .field("openai_api_key", &"<redacted>")
181 .field("id_token", &"<redacted>")
182 .field("access_token", &"<redacted>")
183 .field("refresh_token", &"<redacted>")
184 .field("account_id", &self.account_id)
185 .field("email", &self.email)
186 .field("plan", &self.plan)
187 .field("obtained_at", &self.obtained_at)
188 .field("refreshed_at", &self.refreshed_at)
189 .field("expires_at", &self.expires_at)
190 .finish()
191 }
192}
193
194impl OpenAIChatGptSession {
195 fn is_refresh_due(&self) -> bool {
196 let now = now_secs();
197 if let Some(expires_at) = self.expires_at
198 && now.saturating_add(REFRESH_SKEW_SECS) >= expires_at
199 {
200 return true;
201 }
202 now.saturating_sub(self.refreshed_at) >= REFRESH_INTERVAL_SECS
203 }
204}
205
206#[async_trait]
208pub trait OpenAIChatGptSessionRefresher: Send + Sync {
209 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession>;
210}
211
212#[derive(Clone)]
213enum OpenAIChatGptAuthRefreshStrategy {
214 Stored {
215 storage_mode: AuthCredentialsStoreMode,
216 },
217 External {
218 refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
219 },
220}
221
222impl fmt::Debug for OpenAIChatGptAuthRefreshStrategy {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 match self {
225 Self::Stored { storage_mode } => f.debug_struct("Stored").field("storage_mode", storage_mode).finish(),
226 Self::External { .. } => f.debug_struct("External").finish_non_exhaustive(),
227 }
228 }
229}
230
231#[derive(Clone)]
233pub struct OpenAIChatGptAuthHandle {
234 session: Arc<Mutex<OpenAIChatGptSession>>,
235 refresh_gate: Arc<AsyncMutex<()>>,
236 auto_refresh: bool,
237 refresh_strategy: OpenAIChatGptAuthRefreshStrategy,
238}
239
240impl fmt::Debug for OpenAIChatGptAuthHandle {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 f.debug_struct("OpenAIChatGptAuthHandle")
243 .field("auto_refresh", &self.auto_refresh)
244 .field("refresh_strategy", &self.refresh_strategy)
245 .finish()
246 }
247}
248
249impl OpenAIChatGptAuthHandle {
250 pub fn new(
251 session: OpenAIChatGptSession,
252 auth_config: OpenAIAuthConfig,
253 storage_mode: AuthCredentialsStoreMode,
254 ) -> Self {
255 Self {
256 session: Arc::new(Mutex::new(session)),
257 refresh_gate: Arc::new(AsyncMutex::new(())),
258 auto_refresh: auth_config.auto_refresh,
259 refresh_strategy: OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode },
260 }
261 }
262
263 pub fn new_external(
264 session: OpenAIChatGptSession,
265 auto_refresh: bool,
266 refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
267 ) -> Self {
268 Self {
269 session: Arc::new(Mutex::new(session)),
270 refresh_gate: Arc::new(AsyncMutex::new(())),
271 auto_refresh,
272 refresh_strategy: OpenAIChatGptAuthRefreshStrategy::External { refresher },
273 }
274 }
275
276 pub fn snapshot(&self) -> Result<OpenAIChatGptSession> {
277 self.session
278 .lock()
279 .map(|guard| guard.clone())
280 .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))
281 }
282
283 pub fn current_api_key(&self) -> Result<String> {
284 self.snapshot().map(|session| active_api_bearer_token(&session).to_string())
285 }
286
287 pub fn provider_label(&self) -> &'static str {
288 "OpenAI (ChatGPT)"
289 }
290
291 pub async fn refresh_if_needed(&self) -> Result<()> {
292 if !self.auto_refresh {
293 return Ok(());
294 }
295
296 self.refresh_when(|session| session.is_refresh_due()).await
297 }
298
299 pub async fn force_refresh(&self) -> Result<()> {
300 self.refresh_when(|_| true).await
301 }
302
303 async fn refresh_when<P>(&self, should_refresh: P) -> Result<()>
304 where
305 P: FnOnce(&OpenAIChatGptSession) -> bool,
306 {
307 let _refresh_guard = self.refresh_gate.lock().await;
308 let session = self.snapshot()?;
309 if !should_refresh(&session) {
310 return Ok(());
311 }
312
313 let refreshed = match &self.refresh_strategy {
314 OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode } => {
315 refresh_openai_chatgpt_session_from_snapshot(&session, *storage_mode).await?
316 }
317 OpenAIChatGptAuthRefreshStrategy::External { refresher } => refresher.refresh_session(&session).await?,
318 };
319 self.replace_session(refreshed)
320 }
321
322 #[must_use]
323 fn using_external_tokens(&self) -> bool {
324 matches!(self.refresh_strategy, OpenAIChatGptAuthRefreshStrategy::External { .. })
325 }
326
327 fn replace_session(&self, session: OpenAIChatGptSession) -> Result<()> {
328 let mut guard = self.session.lock().map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))?;
329 *guard = session;
330 Ok(())
331 }
332}
333
334#[derive(Clone)]
338pub enum OpenAIResolvedAuth {
339 ApiKey {
340 api_key: String,
341 },
342 ChatGpt {
343 api_key: String,
344 handle: OpenAIChatGptAuthHandle,
345 },
346}
347
348impl fmt::Debug for OpenAIResolvedAuth {
349 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350 match self {
351 Self::ApiKey { .. } => f.debug_struct("OpenAIResolvedAuth::ApiKey").finish(),
352 Self::ChatGpt { .. } => f.debug_struct("OpenAIResolvedAuth::ChatGpt").finish(),
353 }
354 }
355}
356
357impl OpenAIResolvedAuth {
358 pub fn api_key(&self) -> &str {
359 match self {
360 Self::ApiKey { api_key } => api_key,
361 Self::ChatGpt { api_key, .. } => api_key,
362 }
363 }
364
365 pub fn handle(&self) -> Option<OpenAIChatGptAuthHandle> {
366 match self {
367 Self::ApiKey { .. } => None,
368 Self::ChatGpt { handle, .. } => Some(handle.clone()),
369 }
370 }
371
372 fn using_chatgpt(&self) -> bool {
373 matches!(self, Self::ChatGpt { .. })
374 }
375}
376
377fn active_api_bearer_token(session: &OpenAIChatGptSession) -> &str {
378 if session.openai_api_key.trim().is_empty() {
379 session.access_token.as_str()
380 } else {
381 session.openai_api_key.as_str()
382 }
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub enum OpenAIResolvedAuthSource {
387 ApiKey,
388 ChatGpt,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum OpenAIChatGptSessionProvenance {
395 Native,
397 CodexFallback,
399}
400
401#[derive(Debug, Clone)]
406pub struct OpenAICredentialOverview {
407 pub api_key_available: bool,
408 pub chatgpt_email: Option<String>,
410 pub chatgpt_plan: Option<String>,
412 pub chatgpt_session_present: bool,
414 pub chatgpt_session_provenance: Option<OpenAIChatGptSessionProvenance>,
416 pub codex_fallback_available: bool,
419 pub active_source: Option<OpenAIResolvedAuthSource>,
420 pub preferred_method: OpenAIPreferredMethod,
421 pub notice: Option<String>,
422 pub recommendation: Option<String>,
423}
424
425#[derive(Debug, Clone)]
427pub enum OpenAIChatGptAuthStatus {
428 Authenticated {
429 label: Option<String>,
430 age_seconds: u64,
431 expires_in: Option<u64>,
432 },
433 NotAuthenticated,
434}
435
436pub fn get_openai_chatgpt_auth_url(challenge: &PkceChallenge, callback_port: u16, state: &str) -> Result<String> {
438 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
439 let identity = resolve_oauth_client_identity()?;
440 let query = [
441 ("response_type", "code".to_string()),
442 ("client_id", identity.client_id.clone()),
443 ("redirect_uri", redirect_uri),
444 ("scope", "openid profile email offline_access api.connectors.read api.connectors.invoke".to_string()),
445 ("code_challenge", challenge.code_challenge.clone()),
446 ("code_challenge_method", challenge.code_challenge_method.clone()),
447 ("id_token_add_organizations", "true".to_string()),
448 ("codex_cli_simplified_flow", "true".to_string()),
449 ("state", state.to_string()),
450 ("originator", identity.originator),
451 ];
452
453 let encoded = query
454 .iter()
455 .map(|(key, value)| format!("{key}={}", urlencoding::encode(value)))
456 .collect::<Vec<_>>()
457 .join("&");
458 Ok(format!("{OPENAI_AUTH_URL}?{encoded}"))
459}
460
461pub fn generate_openai_oauth_state() -> Result<String> {
462 let mut state_bytes = [0_u8; 32];
463 SystemRandom::new()
464 .fill(&mut state_bytes)
465 .map_err(|_| anyhow!("failed to generate openai oauth state"))?;
466 Ok(URL_SAFE_NO_PAD.encode(state_bytes))
467}
468
469pub fn parse_openai_chatgpt_manual_callback_input(input: &str, expected_state: &str) -> Result<String> {
470 let trimmed = input.trim();
471 if trimmed.is_empty() {
472 bail!("missing authorization callback input");
473 }
474
475 let query = if trimmed.contains("://") {
476 let url = reqwest::Url::parse(trimmed).context("invalid callback url")?;
477 url.query()
478 .ok_or_else(|| anyhow!("callback url did not include a query string"))?
479 .to_string()
480 } else if trimmed.contains('=') {
481 trimmed.trim_start_matches('?').to_string()
482 } else {
483 bail!("paste the full redirect url or query string containing code and state");
484 };
485
486 let code = extract_query_value(&query, "code")
487 .ok_or_else(|| anyhow!("callback input did not include an authorization code"))?;
488 let state = extract_query_value(&query, "state").ok_or_else(|| anyhow!("callback input did not include state"))?;
489 if state != expected_state {
490 bail!("OAuth error: state mismatch");
491 }
492 Ok(code)
493}
494
495pub async fn exchange_openai_chatgpt_code_for_tokens(
497 code: &str,
498 challenge: &PkceChallenge,
499 callback_port: u16,
500) -> Result<OpenAIChatGptSession> {
501 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
502 let identity = resolve_oauth_client_identity()?;
503 let body = format!(
504 "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
505 urlencoding::encode(code),
506 urlencoding::encode(&redirect_uri),
507 urlencoding::encode(&identity.client_id),
508 urlencoding::encode(&challenge.code_verifier),
509 );
510
511 let token_response: OpenAITokenResponse = Client::new()
512 .post(OPENAI_TOKEN_URL)
513 .header("Content-Type", "application/x-www-form-urlencoded")
514 .body(body)
515 .send()
516 .await
517 .context("failed to exchange openai authorization code")?
518 .error_for_status()
519 .context("openai authorization-code exchange failed")?
520 .json()
521 .await
522 .context("failed to parse openai authorization-code response")?;
523
524 build_session_from_token_response(token_response).await
525}
526
527pub fn resolve_openai_auth(
529 auth_config: &OpenAIAuthConfig,
530 storage_mode: AuthCredentialsStoreMode,
531 api_key: Option<String>,
532) -> Result<OpenAIResolvedAuth> {
533 crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode).resolve_runtime_auth(api_key)
534}
535
536pub fn summarize_openai_credentials(
537 auth_config: &OpenAIAuthConfig,
538 storage_mode: AuthCredentialsStoreMode,
539 api_key: Option<String>,
540) -> Result<OpenAICredentialOverview> {
541 crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode).summarize_credentials(api_key)
542}
543
544pub fn save_openai_chatgpt_session(session: &OpenAIChatGptSession) -> Result<()> {
545 save_openai_chatgpt_session_with_mode(session, AuthCredentialsStoreMode::default())
546}
547
548pub fn save_openai_chatgpt_session_with_mode(
549 session: &OpenAIChatGptSession,
550 mode: AuthCredentialsStoreMode,
551) -> Result<()> {
552 OpenAiSessionStorage::new().save(session, mode)
553}
554
555pub fn load_openai_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
556 OpenAiSessionStorage::new().load(AuthCredentialsStoreMode::Keyring)
557}
558
559pub fn load_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenAIChatGptSession>> {
560 OpenAiSessionStorage::new().load(mode)
561}
562
563pub fn clear_openai_chatgpt_session() -> Result<()> {
564 OpenAiSessionStorage::new().clear_all()
565}
566
567pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
568 OpenAiSessionStorage::new().clear(mode)
569}
570
571pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
572 get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
573}
574
575pub fn get_openai_chatgpt_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptAuthStatus> {
576 let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
577 return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
578 };
579 let now = now_secs();
580 Ok(OpenAIChatGptAuthStatus::Authenticated {
581 label: session
582 .email
583 .clone()
584 .or_else(|| session.plan.clone())
585 .or_else(|| session.account_id.clone()),
586 age_seconds: now.saturating_sub(session.obtained_at),
587 expires_in: session.expires_at.map(|expires_at| expires_at.saturating_sub(now)),
588 })
589}
590
591pub async fn refresh_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptSession> {
592 let session = load_openai_chatgpt_session_with_mode(mode)?.ok_or_else(|| anyhow!("Run vtcode login openai"))?;
593 refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
594}
595
596async fn refresh_openai_chatgpt_session_from_snapshot(
597 session: &OpenAIChatGptSession,
598 storage_mode: AuthCredentialsStoreMode,
599) -> Result<OpenAIChatGptSession> {
600 let _lock = acquire_refresh_lock().await?;
601 if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
602 && session_has_newer_refresh_state(¤t, session)
603 {
604 return Ok(current);
605 }
606 refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
607}
608
609async fn refresh_openai_chatgpt_session_without_lock(
616 current: &OpenAIChatGptSession,
617 storage_mode: AuthCredentialsStoreMode,
618) -> Result<OpenAIChatGptSession> {
619 let identity = resolve_oauth_client_identity()?;
620 let response = Client::new()
621 .post(OPENAI_TOKEN_URL)
622 .header("Content-Type", "application/x-www-form-urlencoded")
623 .body(format!(
624 "grant_type=refresh_token&client_id={}&refresh_token={}",
625 urlencoding::encode(&identity.client_id),
626 urlencoding::encode(¤t.refresh_token),
627 ))
628 .send()
629 .await
630 .context("failed to refresh openai chatgpt token")?;
631
632 if !response.status().is_success() {
636 let status = response.status();
637 let body_text = read_bounded_text(response, MAX_ERROR_BODY_BYTES).await;
639 return Err(classify_refresh_status_error(status, &body_text));
640 }
641
642 let refresh_response: OpenAIRefreshResponse =
643 response.json().await.context("failed to parse openai refresh response")?;
644
645 let session = merge_refresh_response(current, refresh_response).await?;
646 if session.access_token.trim().is_empty() {
651 bail!("openai token refresh returned no access token — the session cannot be used");
652 }
653 save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
654 Ok(session)
655}
656
657async fn merge_refresh_response(
661 current: &OpenAIChatGptSession,
662 resp: OpenAIRefreshResponse,
663) -> Result<OpenAIChatGptSession> {
664 let now = now_secs();
665 let has_new_id_token = resp.id_token.as_deref().is_some_and(|v| !v.trim().is_empty());
669 let has_new_access_token = resp.access_token.as_deref().is_some_and(|v| !v.trim().is_empty());
670 let new_id_token = resp
671 .id_token
672 .filter(|v| !v.trim().is_empty())
673 .unwrap_or_else(|| current.id_token.clone());
674 let new_access_token = resp
675 .access_token
676 .filter(|v| !v.trim().is_empty())
677 .unwrap_or_else(|| current.access_token.clone());
678 let new_refresh_token = resp
679 .refresh_token
680 .filter(|v| !v.trim().is_empty())
681 .unwrap_or_else(|| current.refresh_token.clone());
682
683 let openai_api_key = if has_new_id_token {
685 match exchange_openai_chatgpt_api_key(&new_id_token).await {
686 Ok(api_key) => api_key,
687 Err(err) => {
688 tracing::warn!("openai api-key exchange unavailable, falling back to previous key: {err}");
689 current.openai_api_key.clone()
690 }
691 }
692 } else {
693 current.openai_api_key.clone()
694 };
695
696 let access_token_changed = has_new_access_token && new_access_token != current.access_token;
706 let expires_at = if let Some(secs) = resp.expires_in {
707 Some(now.saturating_add(secs))
708 } else if access_token_changed {
709 parse_jwt_exp(&new_access_token)
710 } else {
711 current.expires_at
712 };
713
714 let (email, plan, account_id) = if has_new_id_token {
716 let id_claims = parse_jwt_claims(&new_id_token)?;
717 let access_claims = parse_jwt_claims(&new_access_token).ok();
718 let email = id_claims.email.clone();
719 let plan = access_claims.as_ref().and_then(|c| c.plan.clone()).or(id_claims.plan);
720 let account_id = access_claims
721 .as_ref()
722 .and_then(|c| c.account_id.clone())
723 .or(id_claims.account_id);
724 (email, plan, account_id)
725 } else {
726 (current.email.clone(), current.plan.clone(), current.account_id.clone())
727 };
728
729 Ok(OpenAIChatGptSession {
730 openai_api_key,
731 id_token: new_id_token,
732 access_token: new_access_token,
733 refresh_token: new_refresh_token,
734 account_id,
735 email,
736 plan,
737 obtained_at: current.obtained_at,
739 refreshed_at: now,
740 expires_at,
741 })
742}
743
744async fn build_session_from_token_response(token_response: OpenAITokenResponse) -> Result<OpenAIChatGptSession> {
745 if token_response.access_token.trim().is_empty() {
747 bail!("openai authorization-code response did not include a usable access token");
748 }
749 if token_response.refresh_token.trim().is_empty() {
750 bail!("openai authorization-code response did not include a usable refresh token");
751 }
752 let id_claims = parse_jwt_claims(&token_response.id_token)?;
753 let access_claims = parse_jwt_claims(&token_response.access_token).ok();
754 let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
755 Ok(api_key) => api_key,
756 Err(err) => {
757 tracing::warn!("openai api-key exchange unavailable, falling back to oauth access token: {err}");
758 String::new()
759 }
760 };
761 let now = now_secs();
762 Ok(OpenAIChatGptSession {
763 openai_api_key: api_key,
764 id_token: token_response.id_token,
765 access_token: token_response.access_token,
766 refresh_token: token_response.refresh_token,
767 account_id: access_claims
768 .as_ref()
769 .and_then(|claims| claims.account_id.clone())
770 .or(id_claims.account_id),
771 email: id_claims
772 .email
773 .or_else(|| access_claims.as_ref().and_then(|claims| claims.email.clone())),
774 plan: access_claims.as_ref().and_then(|claims| claims.plan.clone()).or(id_claims.plan),
775 obtained_at: now,
776 refreshed_at: now,
777 expires_at: token_response.expires_in.map(|secs| now.saturating_add(secs)),
778 })
779}
780
781async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
782 #[derive(Deserialize)]
783 struct ExchangeResponse {
784 access_token: String,
785 }
786
787 let identity = resolve_oauth_client_identity()?;
788 let exchange: ExchangeResponse = Client::new()
789 .post(OPENAI_TOKEN_URL)
790 .header("Content-Type", "application/x-www-form-urlencoded")
791 .body(format!(
792 "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
793 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
794 urlencoding::encode(&identity.client_id),
795 urlencoding::encode("openai-api-key"),
796 urlencoding::encode(id_token),
797 urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
798 ))
799 .send()
800 .await
801 .context("failed to exchange openai id token for api key")?
802 .error_for_status()
803 .context("openai api-key exchange failed")?
804 .json()
805 .await
806 .context("failed to parse openai api-key exchange response")?;
807
808 Ok(exchange.access_token)
809}
810
811#[derive(Deserialize)]
812struct OpenAITokenResponse {
813 id_token: String,
814 access_token: String,
815 refresh_token: String,
816 #[serde(default)]
817 expires_in: Option<u64>,
818}
819
820#[derive(Deserialize)]
825struct OpenAIRefreshResponse {
826 #[serde(default)]
827 id_token: Option<String>,
828 #[serde(default)]
829 access_token: Option<String>,
830 #[serde(default)]
831 refresh_token: Option<String>,
832 #[serde(default)]
833 expires_in: Option<u64>,
834}
835
836#[derive(Debug, Deserialize)]
837struct IdTokenClaims {
838 #[serde(default)]
839 email: Option<String>,
840 #[serde(rename = "https://api.openai.com/profile", default)]
841 profile: Option<ProfileClaims>,
842 #[serde(rename = "https://api.openai.com/auth", default)]
843 auth: Option<AuthClaims>,
844}
845
846#[derive(Debug, Deserialize)]
847struct ProfileClaims {
848 #[serde(default)]
849 email: Option<String>,
850}
851
852#[derive(Debug, Deserialize)]
853struct AuthClaims {
854 #[serde(default)]
855 chatgpt_plan_type: Option<String>,
856 #[serde(default)]
857 chatgpt_account_id: Option<String>,
858}
859
860#[derive(Debug)]
861pub(crate) struct ParsedIdTokenClaims {
862 pub(crate) email: Option<String>,
863 pub(crate) account_id: Option<String>,
864 pub(crate) plan: Option<String>,
865}
866
867pub(crate) fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
868 let mut parts = jwt.split('.');
869 let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
870 (Some(header), Some(payload), Some(signature))
871 if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
872 {
873 (header, payload, signature)
874 }
875 _ => bail!("invalid openai id token"),
876 };
877
878 let payload = URL_SAFE_NO_PAD
879 .decode(payload_b64)
880 .context("failed to decode openai id token payload")?;
881 let claims: IdTokenClaims = serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
882
883 Ok(ParsedIdTokenClaims {
884 email: claims.email.or_else(|| claims.profile.and_then(|profile| profile.email)),
885 account_id: claims.auth.as_ref().and_then(|auth| auth.chatgpt_account_id.clone()),
886 plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
887 })
888}
889
890pub(crate) fn parse_jwt_exp(jwt: &str) -> Option<u64> {
896 let mut parts = jwt.split('.');
897 let _ = parts.next()?;
898 let payload_b64 = parts.next()?;
899 if payload_b64.is_empty() {
900 return None;
901 }
902 let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
903 #[derive(Deserialize)]
904 struct ExpClaim {
905 #[serde(default)]
906 exp: Option<u64>,
907 }
908 let claims: ExpClaim = serde_json::from_slice(&payload).ok()?;
909 claims.exp
910}
911
912fn extract_query_value(query: &str, key: &str) -> Option<String> {
913 query
914 .trim_start_matches('?')
915 .split('&')
916 .filter_map(|pair| {
917 let (pair_key, pair_value) = pair.split_once('=')?;
918 (pair_key == key)
919 .then(|| urlencoding::decode(pair_value).ok().map(|value| value.into_owned()))
920 .flatten()
921 })
922 .find(|value| !value.is_empty())
923}
924
925fn session_has_newer_refresh_state(current: &OpenAIChatGptSession, previous: &OpenAIChatGptSession) -> bool {
926 current.refresh_token != previous.refresh_token
927 || current.refreshed_at > previous.refreshed_at
928 || current.obtained_at > previous.obtained_at
929}
930
931struct RefreshLockGuard {
932 file: fs::File,
933}
934
935impl Drop for RefreshLockGuard {
936 fn drop(&mut self) {
937 drop(FileExt::unlock(&self.file));
938 }
939}
940
941async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
942 let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
943 let file = OpenOptions::new()
944 .create(true)
945 .read(true)
946 .write(true)
947 .truncate(false)
948 .open(&path)
949 .context("failed to open openai refresh lock")?;
950 let file = tokio::task::spawn_blocking(move || {
951 file.lock_exclusive().context("failed to acquire openai refresh lock")?;
952 Ok::<_, anyhow::Error>(file)
953 })
954 .await
955 .context("openai refresh lock task failed")??;
956 Ok(RefreshLockGuard { file })
957}
958
959async fn read_bounded_text(mut response: reqwest::Response, max_bytes: usize) -> String {
967 let mut buf = Vec::with_capacity(max_bytes.min(8 * 1024));
968 while buf.len() < max_bytes {
969 match response.chunk().await {
970 Ok(Some(chunk)) => {
971 let remaining = max_bytes - buf.len();
972 if chunk.len() <= remaining {
973 buf.extend_from_slice(&chunk);
974 } else {
975 buf.extend_from_slice(chunk.get(..remaining).unwrap_or_default());
976 break;
977 }
978 }
979 Ok(None) => break,
980 Err(_) => break,
981 }
982 }
983 String::from_utf8_lossy(&buf).into_owned()
984}
985
986fn classify_refresh_status_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
987 let failure = classify_refresh_failure(status, body);
988 if failure.action() == RefreshFailureAction::ClearStoredSession {
989 if let Err(clear_err) = clear_session_from_all_stores() {
990 tracing::warn!("failed to clear expired openai chatgpt session across all stores: {clear_err}");
991 }
992 }
993 failure.into_error()
994}
995
996fn clear_session_from_all_stores() -> Result<()> {
997 OpenAiSessionStorage::new().clear_all()
998}
999
1000fn now_secs() -> u64 {
1001 std::time::SystemTime::now()
1002 .duration_since(std::time::UNIX_EPOCH)
1003 .map(|duration| duration.as_secs())
1004 .unwrap_or(0)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010 use crate::AuthCallbackOutcome;
1011 use crate::generate_pkce_challenge;
1012 use assert_fs::TempDir;
1013 use serial_test::serial;
1014 use std::path::PathBuf;
1015 use std::sync::Arc;
1016
1017 struct ExternalRefresher;
1018
1019 #[async_trait]
1020 impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1021 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1022 let mut refreshed = current.clone();
1023 refreshed.access_token = "oauth-access-refreshed".to_string();
1024 refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1025 refreshed.expires_at = Some(now_secs() + 3600);
1026 Ok(refreshed)
1027 }
1028 }
1029
1030 struct TestAuthDirGuard {
1031 temp_dir: Option<TempDir>,
1032 codex_temp_dir: Option<TempDir>,
1033 previous: Option<PathBuf>,
1034 previous_codex_home: Option<String>,
1035 }
1036
1037 impl TestAuthDirGuard {
1038 fn new() -> Self {
1039 let temp_dir = TempDir::new().expect("create temp auth dir");
1040 let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
1041 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
1042 .expect("set temp auth dir override");
1043
1044 let codex_temp_dir = TempDir::new().expect("create temp codex home");
1047 let previous_codex_home = std::env::var("CODEX_HOME").ok();
1048 vtcode_commons::env_lock::set_var("CODEX_HOME", codex_temp_dir.path());
1049
1050 Self {
1051 temp_dir: Some(temp_dir),
1052 codex_temp_dir: Some(codex_temp_dir),
1053 previous,
1054 previous_codex_home,
1055 }
1056 }
1057 }
1058
1059 impl Drop for TestAuthDirGuard {
1060 fn drop(&mut self) {
1061 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1062 .expect("restore auth dir override");
1063 if let Some(temp_dir) = self.temp_dir.take() {
1064 temp_dir.close().expect("remove temp auth dir");
1065 }
1066 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", self.previous_codex_home.as_deref());
1067 if let Some(codex_temp_dir) = self.codex_temp_dir.take() {
1068 codex_temp_dir.close().expect("remove temp codex home");
1069 }
1070 }
1071 }
1072
1073 fn sample_session() -> OpenAIChatGptSession {
1074 OpenAIChatGptSession {
1075 openai_api_key: "api-key".to_string(),
1076 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1077 access_token: "oauth-access".to_string(),
1078 refresh_token: "refresh-token".to_string(),
1079 account_id: Some("acc_123".to_string()),
1080 email: Some("test@example.com".to_string()),
1081 plan: Some("plus".to_string()),
1082 obtained_at: 10,
1083 refreshed_at: 10,
1084 expires_at: Some(now_secs() + 3600),
1085 }
1086 }
1087
1088 #[test]
1089 fn auth_url_contains_expected_openai_parameters() {
1090 let env = OauthEnvGuard::new();
1092 env.remove_client_id();
1093 env.remove_originator();
1094
1095 let challenge = PkceChallenge {
1096 code_verifier: "verifier".to_string(),
1097 code_challenge: "challenge".to_string(),
1098 code_challenge_method: "S256".to_string(),
1099 };
1100
1101 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1102 assert!(url.starts_with(OPENAI_AUTH_URL));
1103 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1104 assert!(url.contains("code_challenge=challenge"));
1105 assert!(url.contains("codex_cli_simplified_flow=true"));
1106 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1107 assert!(url.contains("state=test-state"));
1108 }
1109
1110 #[test]
1111 fn auth_url_honors_custom_client_id_env_override() {
1112 let env = OauthEnvGuard::new();
1114 env.set_client_id("app_custom_override");
1115 env.set_originator("vtcode_custom");
1116
1117 let challenge = PkceChallenge {
1118 code_verifier: "verifier".to_string(),
1119 code_challenge: "challenge".to_string(),
1120 code_challenge_method: "S256".to_string(),
1121 };
1122 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1123 assert!(url.contains("client_id=app_custom_override"), "custom client_id not used: {url}");
1124 assert!(url.contains("originator=vtcode_custom"), "custom originator not used: {url}");
1125 assert!(!url.contains("app_EMoamEEZ73f0CkXaXp7hrann"), "default client_id leaked through override: {url}");
1126 }
1127
1128 struct OauthEnvGuard {
1132 env: vtcode_commons::env_lock::EnvGuard,
1133 prev_client_id: Option<std::ffi::OsString>,
1134 prev_originator: Option<std::ffi::OsString>,
1135 }
1136
1137 impl OauthEnvGuard {
1138 fn new() -> Self {
1139 let env = vtcode_commons::env_lock::lock();
1140 let prev_client_id = std::env::var_os("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1141 let prev_originator = std::env::var_os("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1142 Self { env, prev_client_id, prev_originator }
1143 }
1144
1145 fn set_client_id(&self, value: &str) {
1146 self.env.set_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", value);
1147 }
1148
1149 fn set_originator(&self, value: &str) {
1150 self.env.set_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", value);
1151 }
1152
1153 fn remove_client_id(&self) {
1154 self.env.remove_var("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1155 }
1156
1157 fn remove_originator(&self) {
1158 self.env.remove_var("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1159 }
1160 }
1161
1162 impl Drop for OauthEnvGuard {
1163 fn drop(&mut self) {
1164 self.env
1165 .restore_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", self.prev_client_id.take());
1166 self.env
1167 .restore_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", self.prev_originator.take());
1168 }
1169 }
1170
1171 #[test]
1172 fn resolve_oauth_client_identity_both_defaults() {
1173 let env = OauthEnvGuard::new();
1174 env.remove_client_id();
1175 env.remove_originator();
1176
1177 let identity = resolve_oauth_client_identity().expect("defaults");
1178 assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1179 assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1180 }
1181
1182 #[test]
1183 fn resolve_oauth_client_identity_both_custom() {
1184 let env = OauthEnvGuard::new();
1185 env.set_client_id("app_custom");
1186 env.set_originator("my_originator");
1187
1188 let identity = resolve_oauth_client_identity().expect("custom pair");
1189 assert_eq!(identity.client_id, "app_custom");
1190 assert_eq!(identity.originator, "my_originator");
1191 }
1192
1193 #[test]
1194 fn resolve_oauth_client_identity_only_client_id_is_error() {
1195 let env = OauthEnvGuard::new();
1196 env.set_client_id("app_custom");
1197 env.remove_originator();
1198
1199 let err = resolve_oauth_client_identity().unwrap_err();
1200 let msg = err.to_string();
1201 assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the set var: {msg}");
1202 assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the missing var: {msg}");
1203 }
1204
1205 #[test]
1206 fn resolve_oauth_client_identity_only_originator_is_error() {
1207 let env = OauthEnvGuard::new();
1208 env.remove_client_id();
1209 env.set_originator("my_originator");
1210
1211 let err = resolve_oauth_client_identity().unwrap_err();
1212 let msg = err.to_string();
1213 assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the set var: {msg}");
1214 assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the missing var: {msg}");
1215 }
1216
1217 #[test]
1218 fn resolve_oauth_client_identity_blank_values_treated_as_unset() {
1219 let env = OauthEnvGuard::new();
1220 env.set_client_id(" ");
1221 env.set_originator(" ");
1222
1223 let identity = resolve_oauth_client_identity().expect("defaults from blank");
1225 assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1226 assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1227 }
1228
1229 #[test]
1230 fn parse_jwt_claims_extracts_openai_claims() {
1231 let claims = parse_jwt_claims(
1232 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1233 )
1234 .expect("claims");
1235 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1236 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1237 assert_eq!(claims.plan.as_deref(), Some("plus"));
1238 }
1239
1240 #[test]
1241 fn session_refresh_due_uses_expiry_and_age() {
1242 let mut session = sample_session();
1243 let now = now_secs();
1244 session.obtained_at = now;
1245 session.refreshed_at = now;
1246 session.expires_at = Some(now + 3600);
1247 assert!(!session.is_refresh_due());
1248 session.expires_at = Some(now);
1249 assert!(session.is_refresh_due());
1250 }
1251
1252 #[tokio::test]
1253 #[serial]
1254 async fn external_auth_handle_refreshes_without_persisting_session() {
1255 let _guard = TestAuthDirGuard::new();
1256 let mut session = sample_session();
1257 session.openai_api_key.clear();
1258 session.expires_at = Some(now_secs().saturating_sub(1));
1259 let handle = OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1260
1261 assert!(handle.using_external_tokens());
1262 assert!(
1263 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1264 .expect("load session")
1265 .is_none()
1266 );
1267
1268 handle.force_refresh().await.expect("force refresh");
1269
1270 assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1271 assert!(
1272 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1273 .expect("load session")
1274 .is_none()
1275 );
1276 }
1277
1278 struct CountingExternalRefresher {
1279 calls: Arc<Mutex<usize>>,
1280 }
1281
1282 #[async_trait]
1283 impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1284 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1285 let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1286 *calls += 1;
1287 drop(calls);
1288
1289 let mut refreshed = current.clone();
1290 refreshed.access_token = "oauth-access-refreshed".to_string();
1291 refreshed.refreshed_at = now_secs();
1292 refreshed.expires_at = Some(now_secs() + 3600);
1293 Ok(refreshed)
1294 }
1295 }
1296
1297 #[tokio::test]
1298 async fn refresh_if_needed_serializes_external_refreshes() {
1299 let mut session = sample_session();
1300 session.openai_api_key.clear();
1301 session.expires_at = Some(now_secs().saturating_sub(1));
1302 let calls = Arc::new(Mutex::new(0usize));
1303 let handle = OpenAIChatGptAuthHandle::new_external(
1304 session,
1305 true,
1306 Arc::new(CountingExternalRefresher { calls: Arc::clone(&calls) }),
1307 );
1308
1309 let first = handle.clone();
1310 let second = handle.clone();
1311 let (first_result, second_result) = tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1312
1313 first_result.expect("first refresh should succeed");
1314 second_result.expect("second refresh should succeed");
1315 assert_eq!(
1316 *calls.lock().expect("refresh calls mutex should lock"),
1317 1,
1318 "concurrent refresh_if_needed calls should share one refresh"
1319 );
1320 assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1321 }
1322
1323 #[test]
1324 #[serial]
1325 fn resolve_openai_auth_prefers_chatgpt_in_auto_permission() {
1326 let _guard = TestAuthDirGuard::new();
1327 let session = sample_session();
1328 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1329 let resolved = resolve_openai_auth(
1330 &OpenAIAuthConfig::default(),
1331 AuthCredentialsStoreMode::File,
1332 Some("api-key".to_string()),
1333 )
1334 .expect("resolved auth");
1335 assert!(resolved.using_chatgpt());
1336 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1337 }
1338
1339 #[test]
1340 #[serial]
1341 #[cfg(unix)]
1342 fn file_storage_uses_private_permissions() {
1343 use std::fs;
1344 use std::os::unix::fs::PermissionsExt;
1345
1346 let _guard = TestAuthDirGuard::new();
1347 let session = sample_session();
1348
1349 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1350
1351 let metadata = fs::metadata(OpenAiSessionStorage::new().current_file_path().expect("session path"))
1352 .expect("read session metadata");
1353 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1354 }
1355
1356 #[test]
1357 #[serial]
1358 fn legacy_file_session_migrates_to_shared_storage() {
1359 use std::fs;
1360
1361 let _guard = TestAuthDirGuard::new();
1362 let session = sample_session();
1363 let encrypted = encrypt_session(&session).expect("encrypt legacy session");
1364 let legacy_path = get_session_path().expect("legacy session path");
1365 fs::write(&legacy_path, serde_json::to_vec(&encrypted).expect("serialize legacy session"))
1366 .expect("write legacy session");
1367
1368 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1369 .expect("load migrated session")
1370 .expect("session should be present");
1371
1372 assert_eq!(loaded.account_id, session.account_id);
1373 assert!(!legacy_path.exists(), "legacy session should be removed after migration");
1374 assert!(
1375 OpenAiSessionStorage::new()
1376 .current_file_path()
1377 .expect("shared session path")
1378 .exists()
1379 );
1380 }
1381
1382 #[test]
1383 #[serial]
1384 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1385 let _guard = TestAuthDirGuard::new();
1386 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1387 let resolved = resolve_openai_auth(
1388 &OpenAIAuthConfig::default(),
1389 AuthCredentialsStoreMode::File,
1390 Some("api-key".to_string()),
1391 )
1392 .expect("resolved auth");
1393 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1394 }
1395
1396 #[test]
1397 #[serial]
1398 fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1399 let _guard = TestAuthDirGuard::new();
1400 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1401 let error =
1402 resolve_openai_auth(&OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File, Some(" ".to_string()))
1403 .expect_err("blank api key should fail");
1404 assert!(error.to_string().contains("OpenAI API key not found"));
1405 }
1406
1407 #[test]
1408 #[serial]
1409 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1410 let _guard = TestAuthDirGuard::new();
1411 let session = sample_session();
1412 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1413 let resolved = resolve_openai_auth(
1414 &OpenAIAuthConfig {
1415 preferred_method: OpenAIPreferredMethod::ApiKey,
1416 ..OpenAIAuthConfig::default()
1417 },
1418 AuthCredentialsStoreMode::File,
1419 Some("api-key".to_string()),
1420 )
1421 .expect("resolved auth");
1422 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1423 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1424 }
1425
1426 #[test]
1427 #[serial]
1428 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1429 let _guard = TestAuthDirGuard::new();
1430 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1431 let error = resolve_openai_auth(
1432 &OpenAIAuthConfig {
1433 preferred_method: OpenAIPreferredMethod::Chatgpt,
1434 ..OpenAIAuthConfig::default()
1435 },
1436 AuthCredentialsStoreMode::File,
1437 Some("api-key".to_string()),
1438 )
1439 .expect_err("chatgpt mode should require a stored session");
1440 assert!(error.to_string().contains("vtcode login openai"));
1441 }
1442
1443 #[test]
1444 #[serial]
1445 fn summarize_openai_credentials_reports_dual_source_notice() {
1446 let _guard = TestAuthDirGuard::new();
1447 let session = sample_session();
1448 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1449 let overview = summarize_openai_credentials(
1450 &OpenAIAuthConfig::default(),
1451 AuthCredentialsStoreMode::File,
1452 Some("api-key".to_string()),
1453 )
1454 .expect("overview");
1455 assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ChatGpt));
1456 assert!(overview.notice.is_some());
1457 assert!(overview.recommendation.is_some());
1458 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1459 }
1460
1461 #[test]
1462 #[serial]
1463 fn summarize_openai_credentials_respects_api_key_preference() {
1464 let _guard = TestAuthDirGuard::new();
1465 let session = sample_session();
1466 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1467 let overview = summarize_openai_credentials(
1468 &OpenAIAuthConfig {
1469 preferred_method: OpenAIPreferredMethod::ApiKey,
1470 ..OpenAIAuthConfig::default()
1471 },
1472 AuthCredentialsStoreMode::File,
1473 Some("api-key".to_string()),
1474 )
1475 .expect("overview");
1476 assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ApiKey));
1477 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1478 }
1479
1480 #[test]
1481 fn encrypted_file_round_trip_restores_session() {
1482 let session = sample_session();
1483 let encrypted = encrypt_session(&session).expect("encrypt");
1484 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1485 assert_eq!(decrypted.account_id, session.account_id);
1486 assert_eq!(decrypted.email, session.email);
1487 assert_eq!(decrypted.plan, session.plan);
1488 }
1489
1490 #[test]
1491 #[serial]
1492 fn default_loader_falls_back_to_file_session() {
1493 let _guard = TestAuthDirGuard::new();
1494 let session = sample_session();
1495 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1496
1497 let loaded = load_openai_chatgpt_session()
1498 .expect("load session")
1499 .expect("stored session should be found");
1500
1501 assert_eq!(loaded.account_id, session.account_id);
1502 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1503 }
1504
1505 #[test]
1506 #[serial]
1507 fn keyring_mode_loader_falls_back_to_file_session() {
1508 let _guard = TestAuthDirGuard::new();
1509 let session = sample_session();
1510 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1511
1512 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1513 .expect("load session")
1514 .expect("stored session should be found");
1515
1516 assert_eq!(loaded.email, session.email);
1517 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1518 }
1519
1520 #[test]
1521 #[serial]
1522 fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1523 let _guard = TestAuthDirGuard::new();
1524 let session = sample_session();
1525 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save file session");
1526
1527 if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring).is_err() {
1528 clear_openai_chatgpt_session().expect("clear session");
1529 assert!(
1530 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1531 .expect("load file session")
1532 .is_none()
1533 );
1534 return;
1535 }
1536
1537 clear_openai_chatgpt_session().expect("clear session");
1538 assert!(
1539 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1540 .expect("load file session")
1541 .is_none()
1542 );
1543 assert!(
1544 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1545 .expect("load keyring session")
1546 .is_none()
1547 );
1548 }
1549
1550 #[test]
1551 fn active_api_bearer_token_falls_back_to_access_token() {
1552 let mut session = sample_session();
1553 session.openai_api_key.clear();
1554
1555 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1556 }
1557
1558 #[test]
1559 fn parse_manual_callback_input_accepts_full_redirect_url() {
1560 let code = parse_openai_chatgpt_manual_callback_input(
1561 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1562 "test-state",
1563 )
1564 .expect("manual input should parse");
1565 assert_eq!(code, "auth-code");
1566 }
1567
1568 #[test]
1569 fn parse_manual_callback_input_accepts_query_string() {
1570 let code = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=test-state", "test-state")
1571 .expect("manual input should parse");
1572 assert_eq!(code, "auth-code");
1573 }
1574
1575 #[test]
1576 fn parse_manual_callback_input_rejects_bare_code() {
1577 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1578 .expect_err("bare code should be rejected");
1579 assert!(error.to_string().contains("full redirect url or query string"));
1580 }
1581
1582 #[test]
1583 fn parse_manual_callback_input_rejects_state_mismatch() {
1584 let error = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=wrong-state", "test-state")
1585 .expect_err("state mismatch should fail");
1586 assert!(error.to_string().contains("state mismatch"));
1587 }
1588
1589 #[tokio::test]
1590 #[serial]
1591 async fn refresh_lock_serializes_parallel_acquisition() {
1592 let _guard = TestAuthDirGuard::new();
1593 let first = tokio::spawn(async {
1594 let _lock = acquire_refresh_lock().await.expect("first lock");
1595 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1596 });
1597 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1598
1599 let start = std::time::Instant::now();
1600 let second = tokio::spawn(async {
1601 let _lock = acquire_refresh_lock().await.expect("second lock");
1602 });
1603
1604 first.await.expect("first task");
1605 second.await.expect("second task");
1606 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1607 }
1608
1609 #[test]
1612 fn debug_impl_redacts_all_token_fields() {
1613 let session = sample_session();
1614 let debug_str = format!("{session:?}");
1615 assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
1617 assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
1618 assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
1619 assert!(!debug_str.contains("eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20i"), "id_token leaked: {debug_str}");
1621 assert!(debug_str.contains("test@example.com"), "email should be visible: {debug_str}");
1623 assert!(debug_str.contains("plus"), "plan should be visible: {debug_str}");
1624 }
1625
1626 #[test]
1627 fn debug_impl_redacts_resolved_auth_api_key() {
1628 let resolved = OpenAIResolvedAuth::ApiKey { api_key: "sk-secret-key".to_string() };
1629 let debug_str = format!("{resolved:?}");
1630 assert!(!debug_str.contains("sk-secret-key"), "api_key leaked: {debug_str}");
1631 }
1632
1633 #[test]
1634 fn debug_impl_redacts_resolved_auth_chatgpt_handle() {
1635 let _guard = TestAuthDirGuard::new();
1636 let session = sample_session();
1637 let handle = OpenAIChatGptAuthHandle::new(session, OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File);
1638 let resolved = OpenAIResolvedAuth::ChatGpt { api_key: "sk-secret-bearer".to_string(), handle };
1639 let debug_str = format!("{resolved:?}");
1640 assert!(!debug_str.contains("sk-secret-bearer"), "bearer leaked: {debug_str}");
1641 }
1642
1643 #[test]
1644 fn credential_overview_carries_no_token_fields() {
1645 let _guard = TestAuthDirGuard::new();
1646 let session = sample_session();
1647 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1648 let overview = summarize_openai_credentials(
1649 &OpenAIAuthConfig::default(),
1650 AuthCredentialsStoreMode::File,
1651 Some("sk-overview-key".to_string()),
1652 )
1653 .expect("overview");
1654 let debug_str = format!("{overview:?}");
1657 assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
1658 assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
1659 assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
1660 assert!(!debug_str.contains("sk-overview-key"), "api key value leaked: {debug_str}");
1662 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear");
1663 }
1664
1665 #[tokio::test]
1672 async fn merge_preserves_omitted_access_token() {
1673 let current = sample_session();
1674 let resp = OpenAIRefreshResponse {
1675 id_token: None,
1676 access_token: None,
1677 refresh_token: Some("new-refresh".to_string()),
1678 expires_in: Some(3600),
1679 };
1680 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1681 assert_eq!(merged.access_token, current.access_token, "omitted access_token should be preserved");
1682 assert_eq!(merged.refresh_token, "new-refresh");
1683 }
1684
1685 #[tokio::test]
1686 async fn merge_preserves_omitted_refresh_token() {
1687 let current = sample_session();
1688 let resp = OpenAIRefreshResponse {
1689 id_token: None,
1690 access_token: Some("new-access".to_string()),
1691 refresh_token: None,
1692 expires_in: None,
1693 };
1694 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1695 assert_eq!(merged.refresh_token, current.refresh_token, "omitted refresh_token should be preserved");
1696 assert_eq!(merged.access_token, "new-access");
1697 }
1698
1699 #[tokio::test]
1700 async fn merge_preserves_omitted_id_token_and_api_key() {
1701 let current = sample_session();
1702 let resp = OpenAIRefreshResponse {
1703 id_token: None,
1704 access_token: Some("new-access".to_string()),
1705 refresh_token: None,
1706 expires_in: Some(1800),
1707 };
1708 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1709 assert_eq!(merged.id_token, current.id_token, "omitted id_token should be preserved");
1711 assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key should be preserved without new id_token");
1712 assert_eq!(merged.email, current.email);
1714 assert_eq!(merged.plan, current.plan);
1715 }
1716
1717 #[tokio::test]
1718 async fn merge_all_omitted_preserves_everything() {
1719 let current = sample_session();
1720 let resp = OpenAIRefreshResponse {
1721 id_token: None,
1722 access_token: None,
1723 refresh_token: None,
1724 expires_in: None,
1725 };
1726 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1727 assert_eq!(merged.id_token, current.id_token);
1728 assert_eq!(merged.access_token, current.access_token);
1729 assert_eq!(merged.refresh_token, current.refresh_token);
1730 assert_eq!(merged.openai_api_key, current.openai_api_key);
1731 assert_eq!(merged.email, current.email);
1732 assert_eq!(merged.expires_at, current.expires_at);
1734 }
1735
1736 #[tokio::test]
1737 async fn merge_new_access_token_updates_bearer_without_id_token() {
1738 let current = sample_session();
1739 let resp = OpenAIRefreshResponse {
1740 id_token: None,
1741 access_token: Some("replaced-access".to_string()),
1742 refresh_token: None,
1743 expires_in: Some(7200),
1744 };
1745 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1746 assert_eq!(merged.access_token, "replaced-access");
1747 assert_eq!(active_api_bearer_token(&merged), current.openai_api_key);
1750 }
1751
1752 #[tokio::test]
1758 async fn merge_treats_blank_access_token_as_omitted() {
1759 let current = sample_session();
1760 let resp = OpenAIRefreshResponse {
1761 id_token: None,
1762 access_token: Some(" ".to_string()),
1763 refresh_token: Some("new-refresh".to_string()),
1764 expires_in: Some(3600),
1765 };
1766 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1767 assert_eq!(merged.access_token, current.access_token, "blank access_token should be treated as omitted");
1768 assert_eq!(merged.refresh_token, "new-refresh");
1769 }
1770
1771 #[tokio::test]
1772 async fn merge_treats_blank_refresh_token_as_omitted() {
1773 let current = sample_session();
1774 let resp = OpenAIRefreshResponse {
1775 id_token: None,
1776 access_token: Some("new-access".to_string()),
1777 refresh_token: Some(String::new()),
1778 expires_in: None,
1779 };
1780 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1781 assert_eq!(merged.refresh_token, current.refresh_token, "blank refresh_token should be treated as omitted");
1782 assert_eq!(merged.access_token, "new-access");
1783 }
1784
1785 #[tokio::test]
1786 async fn merge_treats_blank_id_token_as_omitted() {
1787 let current = sample_session();
1788 let resp = OpenAIRefreshResponse {
1789 id_token: Some(" ".to_string()),
1790 access_token: Some("new-access".to_string()),
1791 refresh_token: None,
1792 expires_in: None,
1793 };
1794 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1795 assert_eq!(merged.id_token, current.id_token, "blank id_token should be treated as omitted");
1797 assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key preserved when id_token is blank");
1798 }
1799
1800 #[tokio::test]
1803 async fn merge_changed_opaque_access_token_clears_stale_expiry() {
1804 let mut current = sample_session();
1805 current.expires_at = Some(now_secs() + 3600); let resp = OpenAIRefreshResponse {
1810 id_token: None,
1811 access_token: Some("opaque-new-token".to_string()),
1812 refresh_token: None,
1813 expires_in: None,
1814 };
1815 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1816 assert_eq!(merged.access_token, "opaque-new-token");
1817 assert_eq!(merged.expires_at, None, "changed opaque access token must not inherit old token's expiry");
1820 }
1821
1822 #[tokio::test]
1823 async fn merge_repeated_opaque_access_token_preserves_expiry() {
1824 let old_expiry = now_secs() + 3600;
1825 let mut current = sample_session();
1826 current.access_token = "opaque-same-token".to_string();
1827 current.expires_at = Some(old_expiry);
1828
1829 let resp = OpenAIRefreshResponse {
1832 id_token: None,
1833 access_token: Some("opaque-same-token".to_string()),
1834 refresh_token: Some("new-refresh".to_string()),
1835 expires_in: None,
1836 };
1837 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1838 assert_eq!(merged.access_token, "opaque-same-token");
1839 assert_eq!(merged.expires_at, Some(old_expiry), "repeated same opaque access token must preserve old expiry");
1840 }
1841
1842 #[tokio::test]
1843 async fn merge_omitted_access_token_preserves_old_expiry() {
1844 let old_expiry = now_secs() + 3600;
1845 let mut current = sample_session();
1846 current.expires_at = Some(old_expiry);
1847
1848 let resp = OpenAIRefreshResponse {
1849 id_token: None,
1850 access_token: None,
1851 refresh_token: Some("new-refresh".to_string()),
1852 expires_in: None,
1853 };
1854 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1855 assert_eq!(merged.expires_at, Some(old_expiry), "omitted access_token should preserve old expiry");
1857 }
1858
1859 #[tokio::test]
1860 async fn merge_expires_in_overrides_for_omitted_access_token() {
1861 let old_expiry = now_secs() + 3600;
1862 let mut current = sample_session();
1863 current.expires_at = Some(old_expiry);
1864
1865 let resp = OpenAIRefreshResponse {
1866 id_token: None,
1867 access_token: None,
1868 refresh_token: None,
1869 expires_in: Some(1800),
1870 };
1871 let merged = merge_refresh_response(¤t, resp).await.expect("merge");
1872 assert_ne!(merged.expires_at, Some(old_expiry));
1874 assert!(merged.expires_at.is_some());
1875 }
1876
1877 #[test]
1880 fn extract_error_code_flat_form() {
1881 assert_eq!(extract_error_code(r#"{"error": "invalid_grant"}"#), "invalid_grant");
1882 }
1883
1884 #[test]
1885 fn extract_error_code_nested_form() {
1886 let body = r#"{"error": {"code": "refresh_token_expired", "message": "token expired"}}"#;
1887 assert_eq!(extract_error_code(body), "refresh_token_expired");
1888 }
1889
1890 #[test]
1891 fn extract_error_code_nested_form_does_not_fall_back_to_message() {
1892 let body = r#"{"error": {"message": "invalid_grant"}}"#;
1896 assert_eq!(extract_error_code(body), "", "message field should not be used as error code: {body}");
1897 }
1898
1899 #[test]
1900 fn extract_error_code_empty_body() {
1901 assert_eq!(extract_error_code(""), "");
1902 }
1903
1904 #[test]
1905 fn extract_error_code_non_json_body() {
1906 assert_eq!(extract_error_code("Internal Server Error"), "");
1907 }
1908
1909 #[test]
1910 fn extract_error_code_blank_error_field() {
1911 assert_eq!(extract_error_code(r#"{"error": " "}"#), "");
1912 }
1913
1914 #[test]
1915 fn classify_refresh_status_error_invalid_grant_clears_session() {
1916 let _guard = TestAuthDirGuard::new();
1917 let session = sample_session();
1919 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1920 assert!(
1921 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1922 .expect("load")
1923 .is_some()
1924 );
1925
1926 let err = classify_refresh_status_error(reqwest::StatusCode::BAD_REQUEST, r#"{"error": "invalid_grant"}"#);
1927 assert!(err.to_string().contains("session expired"));
1928
1929 assert!(
1931 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1932 .expect("load")
1933 .is_none()
1934 );
1935 }
1936
1937 #[test]
1938 fn classify_refresh_status_error_nested_refresh_token_expired_clears_session() {
1939 let _guard = TestAuthDirGuard::new();
1940 let session = sample_session();
1941 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1942
1943 let err = classify_refresh_status_error(
1944 reqwest::StatusCode::BAD_REQUEST,
1945 r#"{"error": {"code": "refresh_token_expired", "message": "The refresh token has expired"}}"#,
1946 );
1947 assert!(err.to_string().contains("session expired"));
1948
1949 assert!(
1950 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1951 .expect("load")
1952 .is_none()
1953 );
1954 }
1955
1956 #[test]
1957 fn classify_refresh_status_error_unauthorized_preserves_session() {
1958 let _guard = TestAuthDirGuard::new();
1959 let session = sample_session();
1960 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1961
1962 let err = classify_refresh_status_error(reqwest::StatusCode::UNAUTHORIZED, "");
1963 assert!(err.to_string().contains("HTTP 401"), "should report HTTP 401: {err}");
1964 assert!(
1966 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1967 .expect("load")
1968 .is_some(),
1969 "session should be preserved on ambiguous 401"
1970 );
1971 }
1972
1973 #[test]
1974 fn classify_refresh_status_error_server_error_does_not_clear_session() {
1975 let _guard = TestAuthDirGuard::new();
1976 let session = sample_session();
1977 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1978
1979 let err =
1980 classify_refresh_status_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, r#"{"error": "internal_error"}"#);
1981 assert!(err.to_string().contains("HTTP 500"));
1982 assert!(
1984 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1985 .expect("load")
1986 .is_some()
1987 );
1988 }
1989
1990 #[test]
1991 fn classify_refresh_status_error_never_includes_raw_body() {
1992 let _guard = TestAuthDirGuard::new();
1993 let err = classify_refresh_status_error(
1994 reqwest::StatusCode::BAD_REQUEST,
1995 r#"{"error": "invalid_grant", "sensitive_data": "secret-leak-attempt"}"#,
1996 );
1997 assert!(!err.to_string().contains("secret-leak-attempt"), "raw body leaked into error: {err}");
1998 }
1999
2000 #[derive(Debug, PartialEq)]
2004 enum ClassifyOutcome {
2005 Terminal,
2007 Preserved(&'static str),
2009 }
2010
2011 fn run_classify_matrix(status: reqwest::StatusCode, body: &str, expected: ClassifyOutcome) {
2012 let _guard = TestAuthDirGuard::new();
2013 let session = sample_session();
2014 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2015
2016 let err = classify_refresh_status_error(status, body);
2017 let msg = err.to_string();
2018 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("load");
2019
2020 match expected {
2021 ClassifyOutcome::Terminal => {
2022 assert!(msg.contains("session expired"), "terminal error should say 'session expired': {msg}");
2023 assert!(loaded.is_none(), "session should be cleared for terminal: {status} {body}");
2024 }
2025 ClassifyOutcome::Preserved(frag) => {
2026 assert!(!msg.contains("session expired"), "non-terminal should not say 'session expired': {msg}");
2027 assert!(loaded.is_some(), "session should be preserved for: {status} {body}");
2028 if !frag.is_empty() {
2029 assert!(msg.contains(frag), "error should contain '{frag}': {msg}");
2030 }
2031 }
2032 }
2033 }
2034
2035 #[test]
2036 fn classify_matrix_terminal_400_flat() {
2037 run_classify_matrix(
2038 reqwest::StatusCode::BAD_REQUEST,
2039 r#"{"error": "invalid_grant"}"#,
2040 ClassifyOutcome::Terminal,
2041 );
2042 }
2043
2044 #[test]
2045 fn classify_matrix_terminal_400_nested_code() {
2046 run_classify_matrix(
2047 reqwest::StatusCode::BAD_REQUEST,
2048 r#"{"error": {"code": "invalid_grant", "message": "..."}}"#,
2049 ClassifyOutcome::Terminal,
2050 );
2051 }
2052
2053 #[test]
2054 fn classify_matrix_terminal_400_nested_type() {
2055 run_classify_matrix(
2056 reqwest::StatusCode::BAD_REQUEST,
2057 r#"{"error": {"type": "invalid_grant", "message": "..."}}"#,
2058 ClassifyOutcome::Terminal,
2059 );
2060 }
2061
2062 #[test]
2063 fn classify_matrix_terminal_400_toplevel_code() {
2064 run_classify_matrix(
2065 reqwest::StatusCode::BAD_REQUEST,
2066 r#"{"code": "refresh_token_revoked", "message": "..."}"#,
2067 ClassifyOutcome::Terminal,
2068 );
2069 }
2070
2071 #[test]
2072 fn classify_matrix_terminal_401_flat() {
2073 run_classify_matrix(
2075 reqwest::StatusCode::UNAUTHORIZED,
2076 r#"{"error": "invalid_token"}"#,
2077 ClassifyOutcome::Terminal,
2078 );
2079 }
2080
2081 #[test]
2082 fn classify_matrix_terminal_401_nested() {
2083 run_classify_matrix(
2084 reqwest::StatusCode::UNAUTHORIZED,
2085 r#"{"error": {"code": "refresh_token_expired"}}"#,
2086 ClassifyOutcome::Terminal,
2087 );
2088 }
2089
2090 #[test]
2091 fn classify_matrix_terminal_401_refresh_token_invalidated() {
2092 run_classify_matrix(
2093 reqwest::StatusCode::UNAUTHORIZED,
2094 r#"{"error": "refresh_token_invalidated"}"#,
2095 ClassifyOutcome::Terminal,
2096 );
2097 }
2098
2099 #[test]
2100 fn classify_matrix_terminal_400_toplevel_refresh_token_reused() {
2101 run_classify_matrix(
2102 reqwest::StatusCode::BAD_REQUEST,
2103 r#"{"code": "refresh_token_reused", "message": "..."}"#,
2104 ClassifyOutcome::Terminal,
2105 );
2106 }
2107
2108 #[test]
2109 fn classify_matrix_message_only_does_not_clear_session() {
2110 run_classify_matrix(
2115 reqwest::StatusCode::BAD_REQUEST,
2116 r#"{"error": {"message": "invalid_grant"}}"#,
2117 ClassifyOutcome::Preserved("HTTP 400"),
2118 );
2119 }
2120
2121 #[test]
2122 fn classify_matrix_invalid_client_preserves() {
2123 run_classify_matrix(
2124 reqwest::StatusCode::BAD_REQUEST,
2125 r#"{"error": "invalid_client"}"#,
2126 ClassifyOutcome::Preserved("invalid_client"),
2127 );
2128 }
2129
2130 #[test]
2131 fn classify_matrix_invalid_client_401_preserves() {
2132 run_classify_matrix(
2133 reqwest::StatusCode::UNAUTHORIZED,
2134 r#"{"error": "invalid_client"}"#,
2135 ClassifyOutcome::Preserved("invalid_client"),
2136 );
2137 }
2138
2139 #[test]
2140 fn classify_matrix_429_throttling_preserves() {
2141 run_classify_matrix(
2142 reqwest::StatusCode::TOO_MANY_REQUESTS,
2143 r#"{"error": "rate_limited"}"#,
2144 ClassifyOutcome::Preserved("rate-limited"),
2145 );
2146 }
2147
2148 #[test]
2149 fn classify_matrix_500_server_error_preserves() {
2150 run_classify_matrix(
2151 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2152 r#"{"error": "internal_error"}"#,
2153 ClassifyOutcome::Preserved("HTTP 500"),
2154 );
2155 }
2156
2157 #[test]
2158 fn classify_matrix_502_bad_gateway_preserves() {
2159 run_classify_matrix(reqwest::StatusCode::BAD_GATEWAY, "", ClassifyOutcome::Preserved("HTTP 502"));
2160 }
2161
2162 #[test]
2163 fn classify_matrix_503_service_unavailable_preserves() {
2164 run_classify_matrix(reqwest::StatusCode::SERVICE_UNAVAILABLE, "", ClassifyOutcome::Preserved("HTTP 503"));
2165 }
2166
2167 #[test]
2168 fn classify_matrix_ambiguous_401_empty_body_preserves() {
2169 run_classify_matrix(reqwest::StatusCode::UNAUTHORIZED, "", ClassifyOutcome::Preserved("HTTP 401"));
2170 }
2171
2172 #[test]
2173 fn classify_matrix_400_nonterminal_code_preserves() {
2174 run_classify_matrix(
2176 reqwest::StatusCode::BAD_REQUEST,
2177 r#"{"error": "some_unknown_error"}"#,
2178 ClassifyOutcome::Preserved("HTTP 400"),
2179 );
2180 }
2181
2182 #[test]
2183 fn classify_matrix_never_leaks_body_in_any_branch() {
2184 let _guard = TestAuthDirGuard::new();
2186 let err = classify_refresh_status_error(
2187 reqwest::StatusCode::BAD_REQUEST,
2188 r#"{"error": "invalid_grant", "leak": "TERMINAL_LEAK"}"#,
2189 );
2190 assert!(!err.to_string().contains("TERMINAL_LEAK"));
2191
2192 let err = classify_refresh_status_error(
2194 reqwest::StatusCode::BAD_REQUEST,
2195 r#"{"error": "invalid_client", "leak": "CLIENT_LEAK"}"#,
2196 );
2197 assert!(!err.to_string().contains("CLIENT_LEAK"));
2198
2199 let err = classify_refresh_status_error(
2201 reqwest::StatusCode::TOO_MANY_REQUESTS,
2202 r#"{"error": "rate_limited", "leak": "THROTTLE_LEAK"}"#,
2203 );
2204 assert!(!err.to_string().contains("THROTTLE_LEAK"));
2205 }
2206
2207 #[test]
2210 fn extract_error_code_nested_with_type_field() {
2211 let body = r#"{"error": {"type": "invalid_grant", "message": "..."}}"#;
2212 assert_eq!(extract_error_code(body), "invalid_grant");
2213 }
2214
2215 #[test]
2216 fn extract_error_code_toplevel_code_field() {
2217 let body = r#"{"code": "refresh_token_expired", "message": "..."}"#;
2218 assert_eq!(extract_error_code(body), "refresh_token_expired");
2219 }
2220
2221 #[test]
2222 fn extract_error_code_case_insensitive_normalization() {
2223 assert_eq!(extract_error_code(r#"{"error": "INVALID_GRANT"}"#), "invalid_grant");
2224 }
2225
2226 #[test]
2227 fn extract_error_code_no_substring_matching() {
2228 let code = extract_error_code(r#"{"error": "invalid_grant_really_not_a_real_code"}"#);
2231 assert_eq!(code, "invalid_grant_really_not_a_real_code");
2232 let _guard = TestAuthDirGuard::new();
2234 let session = sample_session();
2235 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2236 let err = classify_refresh_status_error(
2237 reqwest::StatusCode::BAD_REQUEST,
2238 r#"{"error": "invalid_grant_really_not_a_real_code"}"#,
2239 );
2240 assert!(!err.to_string().contains("session expired"));
2241 assert!(
2242 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2243 .expect("load")
2244 .is_some()
2245 );
2246 }
2247
2248 #[test]
2251 fn pkce_challenge_debug_redacts_verifier() {
2252 let challenge = generate_pkce_challenge().expect("generate pkce");
2253 let debug_str = format!("{challenge:?}");
2254 assert!(!debug_str.contains(&challenge.code_verifier), "code_verifier leaked: {debug_str}");
2255 assert!(debug_str.contains("<redacted>"), "verifier should be redacted: {debug_str}");
2256 assert!(debug_str.contains(&challenge.code_challenge), "code_challenge should be visible: {debug_str}");
2258 }
2259
2260 #[test]
2261 fn auth_callback_outcome_debug_redacts_code() {
2262 let outcome = AuthCallbackOutcome::Code("super-secret-auth-code".to_string());
2263 let debug_str = format!("{outcome:?}");
2264 assert!(!debug_str.contains("super-secret-auth-code"), "authorization code leaked: {debug_str}");
2265 assert!(debug_str.contains("<redacted>"), "code should be redacted: {debug_str}");
2266 }
2267
2268 #[test]
2269 fn auth_callback_outcome_debug_shows_cancelled_and_redacts_error() {
2270 let cancelled = format!("{:?}", AuthCallbackOutcome::Cancelled);
2271 assert!(cancelled.contains("Cancelled"));
2272
2273 let error = format!("{:?}", AuthCallbackOutcome::Error("access_denied".to_string()));
2276 assert!(!error.contains("access_denied"), "error message leaked through Debug: {error}");
2277 assert!(error.contains("<redacted>"), "error should be redacted: {error}");
2278 }
2279}