1use anyhow::{Context, Result, anyhow, bail};
10use async_trait::async_trait;
11use base64::{Engine, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD};
12use fs2::FileExt;
13use reqwest::Client;
14use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
15use ring::rand::{SecureRandom, SystemRandom};
16use serde::{Deserialize, Serialize};
17use std::fmt;
18use std::fs;
19use std::fs::OpenOptions;
20use std::path::PathBuf;
21use std::sync::{Arc, Mutex};
22use tokio::sync::Mutex as AsyncMutex;
23
24use crate::storage_paths::{auth_storage_dir, write_private_file};
25use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
26
27pub use super::credentials::AuthCredentialsStoreMode;
28use super::credentials::keyring_entry;
29use super::pkce::PkceChallenge;
30
31const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
32const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
33const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
34const OPENAI_ORIGINATOR: &str = "codex_cli_rs";
35const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
36const OPENAI_STORAGE_SERVICE: &str = "vtcode";
37const OPENAI_STORAGE_USER: &str = "openai_chatgpt_session";
38const OPENAI_SESSION_FILE: &str = "openai_chatgpt.json";
39const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
40const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
41const REFRESH_SKEW_SECS: u64 = 60;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct OpenAIChatGptSession {
46 pub openai_api_key: String,
49 pub id_token: String,
51 pub access_token: String,
53 pub refresh_token: String,
55 pub account_id: Option<String>,
57 pub email: Option<String>,
59 pub plan: Option<String>,
61 pub obtained_at: u64,
63 pub refreshed_at: u64,
65 pub expires_at: Option<u64>,
67}
68
69impl OpenAIChatGptSession {
70 pub fn is_refresh_due(&self) -> bool {
71 let now = now_secs();
72 if let Some(expires_at) = self.expires_at
73 && now.saturating_add(REFRESH_SKEW_SECS) >= expires_at
74 {
75 return true;
76 }
77 now.saturating_sub(self.refreshed_at) >= REFRESH_INTERVAL_SECS
78 }
79}
80
81#[async_trait]
83pub trait OpenAIChatGptSessionRefresher: Send + Sync {
84 async fn refresh_session(&self, current: &OpenAIChatGptSession)
85 -> Result<OpenAIChatGptSession>;
86}
87
88#[derive(Clone)]
89enum OpenAIChatGptAuthRefreshStrategy {
90 Stored {
91 storage_mode: AuthCredentialsStoreMode,
92 },
93 External {
94 refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
95 },
96}
97
98impl fmt::Debug for OpenAIChatGptAuthRefreshStrategy {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::Stored { storage_mode } => f
102 .debug_struct("Stored")
103 .field("storage_mode", storage_mode)
104 .finish(),
105 Self::External { .. } => f.debug_struct("External").finish_non_exhaustive(),
106 }
107 }
108}
109
110#[derive(Clone)]
112pub struct OpenAIChatGptAuthHandle {
113 session: Arc<Mutex<OpenAIChatGptSession>>,
114 refresh_gate: Arc<AsyncMutex<()>>,
115 auto_refresh: bool,
116 refresh_strategy: OpenAIChatGptAuthRefreshStrategy,
117}
118
119impl fmt::Debug for OpenAIChatGptAuthHandle {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 f.debug_struct("OpenAIChatGptAuthHandle")
122 .field("auto_refresh", &self.auto_refresh)
123 .field("refresh_strategy", &self.refresh_strategy)
124 .finish()
125 }
126}
127
128impl OpenAIChatGptAuthHandle {
129 pub fn new(
130 session: OpenAIChatGptSession,
131 auth_config: OpenAIAuthConfig,
132 storage_mode: AuthCredentialsStoreMode,
133 ) -> Self {
134 Self {
135 session: Arc::new(Mutex::new(session)),
136 refresh_gate: Arc::new(AsyncMutex::new(())),
137 auto_refresh: auth_config.auto_refresh,
138 refresh_strategy: OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode },
139 }
140 }
141
142 pub fn new_external(
143 session: OpenAIChatGptSession,
144 auto_refresh: bool,
145 refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
146 ) -> Self {
147 Self {
148 session: Arc::new(Mutex::new(session)),
149 refresh_gate: Arc::new(AsyncMutex::new(())),
150 auto_refresh,
151 refresh_strategy: OpenAIChatGptAuthRefreshStrategy::External { refresher },
152 }
153 }
154
155 pub fn snapshot(&self) -> Result<OpenAIChatGptSession> {
156 self.session
157 .lock()
158 .map(|guard| guard.clone())
159 .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))
160 }
161
162 pub fn current_api_key(&self) -> Result<String> {
163 self.snapshot()
164 .map(|session| active_api_bearer_token(&session).to_string())
165 }
166
167 pub fn provider_label(&self) -> &'static str {
168 "OpenAI (ChatGPT)"
169 }
170
171 pub async fn refresh_if_needed(&self) -> Result<()> {
172 if !self.auto_refresh {
173 return Ok(());
174 }
175
176 self.refresh_when(|session| session.is_refresh_due()).await
177 }
178
179 pub async fn force_refresh(&self) -> Result<()> {
180 self.refresh_when(|_| true).await
181 }
182
183 async fn refresh_when<P>(&self, should_refresh: P) -> Result<()>
184 where
185 P: FnOnce(&OpenAIChatGptSession) -> bool,
186 {
187 let _refresh_guard = self.refresh_gate.lock().await;
188 let session = self.snapshot()?;
189 if !should_refresh(&session) {
190 return Ok(());
191 }
192
193 let refreshed = match &self.refresh_strategy {
194 OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode } => {
195 refresh_openai_chatgpt_session_from_snapshot(&session, *storage_mode).await?
196 }
197 OpenAIChatGptAuthRefreshStrategy::External { refresher } => {
198 refresher.refresh_session(&session).await?
199 }
200 };
201 self.replace_session(refreshed)
202 }
203
204 #[must_use]
205 pub fn using_external_tokens(&self) -> bool {
206 matches!(
207 self.refresh_strategy,
208 OpenAIChatGptAuthRefreshStrategy::External { .. }
209 )
210 }
211
212 fn replace_session(&self, session: OpenAIChatGptSession) -> Result<()> {
213 let mut guard = self
214 .session
215 .lock()
216 .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))?;
217 *guard = session;
218 Ok(())
219 }
220}
221
222#[derive(Debug, Clone)]
224pub enum OpenAIResolvedAuth {
225 ApiKey {
226 api_key: String,
227 },
228 ChatGpt {
229 api_key: String,
230 handle: OpenAIChatGptAuthHandle,
231 },
232}
233
234impl OpenAIResolvedAuth {
235 pub fn api_key(&self) -> &str {
236 match self {
237 Self::ApiKey { api_key } => api_key,
238 Self::ChatGpt { api_key, .. } => api_key,
239 }
240 }
241
242 pub fn handle(&self) -> Option<OpenAIChatGptAuthHandle> {
243 match self {
244 Self::ApiKey { .. } => None,
245 Self::ChatGpt { handle, .. } => Some(handle.clone()),
246 }
247 }
248
249 pub fn using_chatgpt(&self) -> bool {
250 matches!(self, Self::ChatGpt { .. })
251 }
252}
253
254fn active_api_bearer_token(session: &OpenAIChatGptSession) -> &str {
255 if session.openai_api_key.trim().is_empty() {
256 session.access_token.as_str()
257 } else {
258 session.openai_api_key.as_str()
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum OpenAIResolvedAuthSource {
264 ApiKey,
265 ChatGpt,
266}
267
268#[derive(Debug, Clone)]
269pub struct OpenAICredentialOverview {
270 pub api_key_available: bool,
271 pub chatgpt_session: Option<OpenAIChatGptSession>,
272 pub active_source: Option<OpenAIResolvedAuthSource>,
273 pub preferred_method: OpenAIPreferredMethod,
274 pub notice: Option<String>,
275 pub recommendation: Option<String>,
276}
277
278#[derive(Debug, Clone)]
280pub enum OpenAIChatGptAuthStatus {
281 Authenticated {
282 label: Option<String>,
283 age_seconds: u64,
284 expires_in: Option<u64>,
285 },
286 NotAuthenticated,
287}
288
289pub fn get_openai_chatgpt_auth_url(
291 challenge: &PkceChallenge,
292 callback_port: u16,
293 state: &str,
294) -> String {
295 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
296 let query = [
297 ("response_type", "code".to_string()),
298 ("client_id", OPENAI_CLIENT_ID.to_string()),
299 ("redirect_uri", redirect_uri),
300 (
301 "scope",
302 "openid profile email offline_access api.connectors.read api.connectors.invoke"
303 .to_string(),
304 ),
305 ("code_challenge", challenge.code_challenge.clone()),
306 (
307 "code_challenge_method",
308 challenge.code_challenge_method.clone(),
309 ),
310 ("id_token_add_organizations", "true".to_string()),
311 ("codex_cli_simplified_flow", "true".to_string()),
312 ("state", state.to_string()),
313 ("originator", OPENAI_ORIGINATOR.to_string()),
314 ];
315
316 let encoded = query
317 .iter()
318 .map(|(key, value)| format!("{key}={}", urlencoding::encode(value)))
319 .collect::<Vec<_>>()
320 .join("&");
321 format!("{OPENAI_AUTH_URL}?{encoded}")
322}
323
324pub fn generate_openai_oauth_state() -> Result<String> {
325 let mut state_bytes = [0_u8; 32];
326 SystemRandom::new()
327 .fill(&mut state_bytes)
328 .map_err(|_| anyhow!("failed to generate openai oauth state"))?;
329 Ok(URL_SAFE_NO_PAD.encode(state_bytes))
330}
331
332pub fn parse_openai_chatgpt_manual_callback_input(
333 input: &str,
334 expected_state: &str,
335) -> Result<String> {
336 let trimmed = input.trim();
337 if trimmed.is_empty() {
338 bail!("missing authorization callback input");
339 }
340
341 let query = if trimmed.contains("://") {
342 let url = reqwest::Url::parse(trimmed).context("invalid callback url")?;
343 url.query()
344 .ok_or_else(|| anyhow!("callback url did not include a query string"))?
345 .to_string()
346 } else if trimmed.contains('=') {
347 trimmed.trim_start_matches('?').to_string()
348 } else {
349 bail!("paste the full redirect url or query string containing code and state");
350 };
351
352 let code = extract_query_value(&query, "code")
353 .ok_or_else(|| anyhow!("callback input did not include an authorization code"))?;
354 let state = extract_query_value(&query, "state")
355 .ok_or_else(|| anyhow!("callback input did not include state"))?;
356 if state != expected_state {
357 bail!("OAuth error: state mismatch");
358 }
359 Ok(code)
360}
361
362pub async fn exchange_openai_chatgpt_code_for_tokens(
364 code: &str,
365 challenge: &PkceChallenge,
366 callback_port: u16,
367) -> Result<OpenAIChatGptSession> {
368 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
369 let body = format!(
370 "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
371 urlencoding::encode(code),
372 urlencoding::encode(&redirect_uri),
373 urlencoding::encode(OPENAI_CLIENT_ID),
374 urlencoding::encode(&challenge.code_verifier),
375 );
376
377 let token_response: OpenAITokenResponse = Client::new()
378 .post(OPENAI_TOKEN_URL)
379 .header("Content-Type", "application/x-www-form-urlencoded")
380 .body(body)
381 .send()
382 .await
383 .context("failed to exchange openai authorization code")?
384 .error_for_status()
385 .context("openai authorization-code exchange failed")?
386 .json()
387 .await
388 .context("failed to parse openai authorization-code response")?;
389
390 build_session_from_token_response(token_response).await
391}
392
393pub fn resolve_openai_auth(
395 auth_config: &OpenAIAuthConfig,
396 storage_mode: AuthCredentialsStoreMode,
397 api_key: Option<String>,
398) -> Result<OpenAIResolvedAuth> {
399 crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode)
400 .resolve_runtime_auth(api_key)
401}
402
403pub fn summarize_openai_credentials(
404 auth_config: &OpenAIAuthConfig,
405 storage_mode: AuthCredentialsStoreMode,
406 api_key: Option<String>,
407) -> Result<OpenAICredentialOverview> {
408 crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode)
409 .summarize_credentials(api_key)
410}
411
412pub fn save_openai_chatgpt_session(session: &OpenAIChatGptSession) -> Result<()> {
413 save_openai_chatgpt_session_with_mode(session, AuthCredentialsStoreMode::default())
414}
415
416pub fn save_openai_chatgpt_session_with_mode(
417 session: &OpenAIChatGptSession,
418 mode: AuthCredentialsStoreMode,
419) -> Result<()> {
420 let serialized =
421 serde_json::to_string(session).context("failed to serialize openai session")?;
422 match mode.effective_mode() {
423 AuthCredentialsStoreMode::Keyring => {
424 persist_session_to_keyring_or_file(session, &serialized)?
425 }
426 AuthCredentialsStoreMode::File => save_session_to_file(session)?,
427 AuthCredentialsStoreMode::Auto => unreachable!(),
428 }
429 Ok(())
430}
431
432pub fn load_openai_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
433 load_preferred_openai_chatgpt_session(AuthCredentialsStoreMode::Keyring)
434}
435
436pub fn load_openai_chatgpt_session_with_mode(
437 mode: AuthCredentialsStoreMode,
438) -> Result<Option<OpenAIChatGptSession>> {
439 load_preferred_openai_chatgpt_session(mode.effective_mode())
440}
441
442pub fn clear_openai_chatgpt_session() -> Result<()> {
443 clear_session_from_all_stores()
444}
445
446pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
447 match mode.effective_mode() {
448 AuthCredentialsStoreMode::Keyring => clear_session_from_keyring(),
449 AuthCredentialsStoreMode::File => clear_session_from_file(),
450 AuthCredentialsStoreMode::Auto => unreachable!(),
451 }
452}
453
454pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
455 get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
456}
457
458pub fn get_openai_chatgpt_auth_status_with_mode(
459 mode: AuthCredentialsStoreMode,
460) -> Result<OpenAIChatGptAuthStatus> {
461 let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
462 return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
463 };
464 let now = now_secs();
465 Ok(OpenAIChatGptAuthStatus::Authenticated {
466 label: session
467 .email
468 .clone()
469 .or_else(|| session.plan.clone())
470 .or_else(|| session.account_id.clone()),
471 age_seconds: now.saturating_sub(session.obtained_at),
472 expires_in: session
473 .expires_at
474 .map(|expires_at| expires_at.saturating_sub(now)),
475 })
476}
477
478pub async fn refresh_openai_chatgpt_session_from_refresh_token(
479 refresh_token: &str,
480 storage_mode: AuthCredentialsStoreMode,
481) -> Result<OpenAIChatGptSession> {
482 let _lock = acquire_refresh_lock().await?;
483 refresh_openai_chatgpt_session_without_lock(refresh_token, storage_mode).await
484}
485
486pub async fn refresh_openai_chatgpt_session_with_mode(
487 mode: AuthCredentialsStoreMode,
488) -> Result<OpenAIChatGptSession> {
489 let session = load_openai_chatgpt_session_with_mode(mode)?
490 .ok_or_else(|| anyhow!("Run vtcode login openai"))?;
491 refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
492}
493
494async fn refresh_openai_chatgpt_session_from_snapshot(
495 session: &OpenAIChatGptSession,
496 storage_mode: AuthCredentialsStoreMode,
497) -> Result<OpenAIChatGptSession> {
498 let _lock = acquire_refresh_lock().await?;
499 if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
500 && session_has_newer_refresh_state(¤t, session)
501 {
502 return Ok(current);
503 }
504 refresh_openai_chatgpt_session_without_lock(&session.refresh_token, storage_mode).await
505}
506
507async fn refresh_openai_chatgpt_session_without_lock(
508 refresh_token: &str,
509 storage_mode: AuthCredentialsStoreMode,
510) -> Result<OpenAIChatGptSession> {
511 let response = Client::new()
512 .post(OPENAI_TOKEN_URL)
513 .header("Content-Type", "application/x-www-form-urlencoded")
514 .body(format!(
515 "grant_type=refresh_token&client_id={}&refresh_token={}",
516 urlencoding::encode(OPENAI_CLIENT_ID),
517 urlencoding::encode(refresh_token),
518 ))
519 .send()
520 .await
521 .context("failed to refresh openai chatgpt token")?;
522 response
523 .error_for_status_ref()
524 .map_err(classify_refresh_error)?;
525 let token_response: OpenAITokenResponse = response
526 .json()
527 .await
528 .context("failed to parse openai refresh response")?;
529
530 let session = build_session_from_token_response(token_response).await?;
531 save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
532 Ok(session)
533}
534
535async fn build_session_from_token_response(
536 token_response: OpenAITokenResponse,
537) -> Result<OpenAIChatGptSession> {
538 let id_claims = parse_jwt_claims(&token_response.id_token)?;
539 let access_claims = parse_jwt_claims(&token_response.access_token).ok();
540 let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
541 Ok(api_key) => api_key,
542 Err(err) => {
543 tracing::warn!(
544 "openai api-key exchange unavailable, falling back to oauth access token: {err}"
545 );
546 String::new()
547 }
548 };
549 let now = now_secs();
550 Ok(OpenAIChatGptSession {
551 openai_api_key: api_key,
552 id_token: token_response.id_token,
553 access_token: token_response.access_token,
554 refresh_token: token_response.refresh_token,
555 account_id: access_claims
556 .as_ref()
557 .and_then(|claims| claims.account_id.clone())
558 .or(id_claims.account_id),
559 email: id_claims.email.or_else(|| {
560 access_claims
561 .as_ref()
562 .and_then(|claims| claims.email.clone())
563 }),
564 plan: access_claims
565 .as_ref()
566 .and_then(|claims| claims.plan.clone())
567 .or(id_claims.plan),
568 obtained_at: now,
569 refreshed_at: now,
570 expires_at: token_response
571 .expires_in
572 .map(|secs| now.saturating_add(secs)),
573 })
574}
575
576async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
577 #[derive(Deserialize)]
578 struct ExchangeResponse {
579 access_token: String,
580 }
581
582 let exchange: ExchangeResponse = Client::new()
583 .post(OPENAI_TOKEN_URL)
584 .header("Content-Type", "application/x-www-form-urlencoded")
585 .body(format!(
586 "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
587 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
588 urlencoding::encode(OPENAI_CLIENT_ID),
589 urlencoding::encode("openai-api-key"),
590 urlencoding::encode(id_token),
591 urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
592 ))
593 .send()
594 .await
595 .context("failed to exchange openai id token for api key")?
596 .error_for_status()
597 .context("openai api-key exchange failed")?
598 .json()
599 .await
600 .context("failed to parse openai api-key exchange response")?;
601
602 Ok(exchange.access_token)
603}
604
605#[derive(Debug, Deserialize)]
606struct OpenAITokenResponse {
607 id_token: String,
608 access_token: String,
609 refresh_token: String,
610 #[serde(default)]
611 expires_in: Option<u64>,
612}
613
614#[derive(Debug, Deserialize)]
615struct IdTokenClaims {
616 #[serde(default)]
617 email: Option<String>,
618 #[serde(rename = "https://api.openai.com/profile", default)]
619 profile: Option<ProfileClaims>,
620 #[serde(rename = "https://api.openai.com/auth", default)]
621 auth: Option<AuthClaims>,
622}
623
624#[derive(Debug, Deserialize)]
625struct ProfileClaims {
626 #[serde(default)]
627 email: Option<String>,
628}
629
630#[derive(Debug, Deserialize)]
631struct AuthClaims {
632 #[serde(default)]
633 chatgpt_plan_type: Option<String>,
634 #[serde(default)]
635 chatgpt_account_id: Option<String>,
636}
637
638#[derive(Debug)]
639struct ParsedIdTokenClaims {
640 email: Option<String>,
641 account_id: Option<String>,
642 plan: Option<String>,
643}
644
645fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
646 let mut parts = jwt.split('.');
647 let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
648 (Some(header), Some(payload), Some(signature))
649 if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
650 {
651 (header, payload, signature)
652 }
653 _ => bail!("invalid openai id token"),
654 };
655
656 let payload = URL_SAFE_NO_PAD
657 .decode(payload_b64)
658 .context("failed to decode openai id token payload")?;
659 let claims: IdTokenClaims =
660 serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
661
662 Ok(ParsedIdTokenClaims {
663 email: claims
664 .email
665 .or_else(|| claims.profile.and_then(|profile| profile.email)),
666 account_id: claims
667 .auth
668 .as_ref()
669 .and_then(|auth| auth.chatgpt_account_id.clone()),
670 plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
671 })
672}
673
674fn extract_query_value(query: &str, key: &str) -> Option<String> {
675 query
676 .trim_start_matches('?')
677 .split('&')
678 .filter_map(|pair| {
679 let (pair_key, pair_value) = pair.split_once('=')?;
680 (pair_key == key)
681 .then(|| {
682 urlencoding::decode(pair_value)
683 .ok()
684 .map(|value| value.into_owned())
685 })
686 .flatten()
687 })
688 .find(|value| !value.is_empty())
689}
690
691fn session_has_newer_refresh_state(
692 current: &OpenAIChatGptSession,
693 previous: &OpenAIChatGptSession,
694) -> bool {
695 current.refresh_token != previous.refresh_token
696 || current.refreshed_at > previous.refreshed_at
697 || current.obtained_at > previous.obtained_at
698}
699
700struct RefreshLockGuard {
701 file: fs::File,
702}
703
704impl Drop for RefreshLockGuard {
705 fn drop(&mut self) {
706 let _ = FileExt::unlock(&self.file);
707 }
708}
709
710async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
711 let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
712 let file = OpenOptions::new()
713 .create(true)
714 .read(true)
715 .write(true)
716 .truncate(false)
717 .open(&path)
718 .with_context(|| format!("failed to open openai refresh lock {}", path.display()))?;
719 let file = tokio::task::spawn_blocking(move || {
720 file.lock_exclusive()
721 .context("failed to acquire openai refresh lock")?;
722 Ok::<_, anyhow::Error>(file)
723 })
724 .await
725 .context("openai refresh lock task failed")??;
726 Ok(RefreshLockGuard { file })
727}
728
729#[cold]
730fn classify_refresh_error(err: reqwest::Error) -> anyhow::Error {
731 let status = err.status();
732 let message = err.to_string();
733 if status.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST)
734 && (message.contains("invalid_grant") || message.contains("refresh_token"))
735 {
736 if let Err(clear_err) = clear_session_from_all_stores() {
737 tracing::warn!(
738 "failed to clear expired openai chatgpt session across all stores: {clear_err}"
739 );
740 }
741 anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
742 } else {
743 anyhow!(message)
744 }
745}
746
747fn clear_session_from_all_stores() -> Result<()> {
748 let mut errors = Vec::new();
749
750 if let Err(err) = clear_session_from_keyring() {
751 errors.push(err.to_string());
752 }
753 if let Err(err) = clear_session_from_file() {
754 errors.push(err.to_string());
755 }
756
757 if errors.is_empty() {
758 Ok(())
759 } else {
760 Err(anyhow!(
761 "failed to clear openai session from all stores: {}",
762 errors.join("; ")
763 ))
764 }
765}
766
767fn save_session_to_keyring(serialized: &str) -> Result<()> {
768 let entry = keyring_entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
769 .context("failed to access keyring for openai session")?;
770 entry
771 .set_password(serialized)
772 .context("failed to store openai session in keyring")?;
773 Ok(())
774}
775
776fn persist_session_to_keyring_or_file(
777 session: &OpenAIChatGptSession,
778 serialized: &str,
779) -> Result<()> {
780 match save_session_to_keyring(serialized) {
781 Ok(()) => match load_session_from_keyring_decoded() {
782 Ok(Some(_)) => Ok(()),
783 Ok(None) => {
784 tracing::warn!(
785 "openai session keyring write did not round-trip; falling back to encrypted file storage"
786 );
787 save_session_to_file(session)
788 }
789 Err(err) => {
790 tracing::warn!(
791 "openai session keyring verification failed, falling back to encrypted file storage: {err}"
792 );
793 save_session_to_file(session)
794 }
795 },
796 Err(err) => {
797 tracing::warn!(
798 "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
799 );
800 save_session_to_file(session)
801 .context("failed to persist openai session after keyring fallback")
802 }
803 }
804}
805
806fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
807 serde_json::from_str(&serialized).context("failed to decode openai session")
808}
809
810fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
811 load_session_from_keyring()?
812 .map(decode_session_from_keyring)
813 .transpose()
814}
815
816fn load_preferred_openai_chatgpt_session(
817 mode: AuthCredentialsStoreMode,
818) -> Result<Option<OpenAIChatGptSession>> {
819 match mode {
820 AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
821 Ok(Some(session)) => Ok(Some(session)),
822 Ok(None) => load_session_from_file(),
823 Err(err) => {
824 tracing::warn!(
825 "failed to load openai session from keyring, falling back to encrypted file: {err}"
826 );
827 load_session_from_file()
828 }
829 },
830 AuthCredentialsStoreMode::File => {
831 if let Some(session) = load_session_from_file()? {
832 return Ok(Some(session));
833 }
834 load_session_from_keyring_decoded()
835 }
836 AuthCredentialsStoreMode::Auto => unreachable!(),
837 }
838}
839
840fn load_session_from_keyring() -> Result<Option<String>> {
841 let entry = match keyring_entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
842 Ok(entry) => entry,
843 Err(_) => return Ok(None),
844 };
845
846 match entry.get_password() {
847 Ok(value) => Ok(Some(value)),
848 Err(keyring_core::Error::NoEntry) => Ok(None),
849 Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
850 }
851}
852
853fn clear_session_from_keyring() -> Result<()> {
854 let entry = match keyring_entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
855 Ok(entry) => entry,
856 Err(_) => return Ok(()),
857 };
858
859 match entry.delete_credential() {
860 Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
861 Err(err) => Err(anyhow!(
862 "failed to clear openai session keyring entry: {err}"
863 )),
864 }
865}
866
867fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
868 let encrypted = encrypt_session(session)?;
869 let path = get_session_path()?;
870 let payload = serde_json::to_vec_pretty(&encrypted)?;
871 write_private_file(&path, &payload).context("failed to persist openai session file")?;
872 Ok(())
873}
874
875fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
876 let path = get_session_path()?;
877 let data = match fs::read(path) {
878 Ok(data) => data,
879 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
880 Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
881 };
882
883 let encrypted: EncryptedSession =
884 serde_json::from_slice(&data).context("failed to decode openai session file")?;
885 Ok(Some(decrypt_session(&encrypted)?))
886}
887
888fn clear_session_from_file() -> Result<()> {
889 let path = get_session_path()?;
890 match fs::remove_file(path) {
891 Ok(()) => Ok(()),
892 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
893 Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
894 }
895}
896
897fn get_session_path() -> Result<PathBuf> {
898 Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
899}
900
901#[derive(Debug, Serialize, Deserialize)]
902struct EncryptedSession {
903 nonce: String,
904 ciphertext: String,
905 version: u8,
906}
907
908fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
909 let key = derive_encryption_key()?;
910 let rng = SystemRandom::new();
911 let mut nonce_bytes = [0u8; NONCE_LEN];
912 rng.fill(&mut nonce_bytes)
913 .map_err(|_| anyhow!("failed to generate nonce"))?;
914
915 let mut ciphertext =
916 serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
917 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
918 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
919 .map_err(|_| anyhow!("failed to encrypt openai session"))?;
920
921 Ok(EncryptedSession {
922 nonce: STANDARD.encode(nonce_bytes),
923 ciphertext: STANDARD.encode(ciphertext),
924 version: 1,
925 })
926}
927
928fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
929 if encrypted.version != 1 {
930 bail!("unsupported openai session encryption format");
931 }
932
933 let nonce_bytes = STANDARD
934 .decode(&encrypted.nonce)
935 .context("failed to decode openai session nonce")?;
936 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
937 .try_into()
938 .map_err(|_| anyhow!("invalid openai session nonce length"))?;
939 let mut ciphertext = STANDARD
940 .decode(&encrypted.ciphertext)
941 .context("failed to decode openai session ciphertext")?;
942
943 let key = derive_encryption_key()?;
944 let plaintext = key
945 .open_in_place(
946 Nonce::assume_unique_for_key(nonce_array),
947 Aad::empty(),
948 &mut ciphertext,
949 )
950 .map_err(|_| anyhow!("failed to decrypt openai session"))?;
951 serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
952}
953
954fn derive_encryption_key() -> Result<LessSafeKey> {
955 use ring::digest::{SHA256, digest};
956
957 let mut key_material = Vec::new();
958 if let Ok(hostname) = hostname::get() {
959 key_material.extend_from_slice(hostname.as_encoded_bytes());
960 }
961
962 #[cfg(unix)]
963 {
964 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
965 }
966 #[cfg(not(unix))]
967 {
968 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
969 key_material.extend_from_slice(user.as_bytes());
970 }
971 }
972
973 key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
974 let hash = digest(&SHA256, &key_material);
975 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
976 .try_into()
977 .context("openai session encryption key was too short")?;
978 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
979 .map_err(|_| anyhow!("invalid openai session encryption key"))?;
980 Ok(LessSafeKey::new(unbound))
981}
982
983fn now_secs() -> u64 {
984 std::time::SystemTime::now()
985 .duration_since(std::time::UNIX_EPOCH)
986 .map(|duration| duration.as_secs())
987 .unwrap_or(0)
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993 use assert_fs::TempDir;
994 use serial_test::serial;
995 use std::sync::Arc;
996
997 struct ExternalRefresher;
998
999 #[async_trait]
1000 impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1001 async fn refresh_session(
1002 &self,
1003 current: &OpenAIChatGptSession,
1004 ) -> Result<OpenAIChatGptSession> {
1005 let mut refreshed = current.clone();
1006 refreshed.access_token = "oauth-access-refreshed".to_string();
1007 refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1008 refreshed.expires_at = Some(now_secs() + 3600);
1009 Ok(refreshed)
1010 }
1011 }
1012
1013 struct TestAuthDirGuard {
1014 temp_dir: Option<TempDir>,
1015 previous: Option<PathBuf>,
1016 }
1017
1018 impl TestAuthDirGuard {
1019 fn new() -> Self {
1020 let temp_dir = TempDir::new().expect("create temp auth dir");
1021 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1022 .expect("read auth dir override");
1023 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1024 temp_dir.path().to_path_buf(),
1025 ))
1026 .expect("set temp auth dir override");
1027 Self {
1028 temp_dir: Some(temp_dir),
1029 previous,
1030 }
1031 }
1032 }
1033
1034 impl Drop for TestAuthDirGuard {
1035 fn drop(&mut self) {
1036 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1037 .expect("restore auth dir override");
1038 if let Some(temp_dir) = self.temp_dir.take() {
1039 temp_dir.close().expect("remove temp auth dir");
1040 }
1041 }
1042 }
1043
1044 fn sample_session() -> OpenAIChatGptSession {
1045 OpenAIChatGptSession {
1046 openai_api_key: "api-key".to_string(),
1047 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1048 access_token: "oauth-access".to_string(),
1049 refresh_token: "refresh-token".to_string(),
1050 account_id: Some("acc_123".to_string()),
1051 email: Some("test@example.com".to_string()),
1052 plan: Some("plus".to_string()),
1053 obtained_at: 10,
1054 refreshed_at: 10,
1055 expires_at: Some(now_secs() + 3600),
1056 }
1057 }
1058
1059 #[test]
1060 fn auth_url_contains_expected_openai_parameters() {
1061 let challenge = PkceChallenge {
1062 code_verifier: "verifier".to_string(),
1063 code_challenge: "challenge".to_string(),
1064 code_challenge_method: "S256".to_string(),
1065 };
1066
1067 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1068 assert!(url.starts_with(OPENAI_AUTH_URL));
1069 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1070 assert!(url.contains("code_challenge=challenge"));
1071 assert!(url.contains("codex_cli_simplified_flow=true"));
1072 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1073 assert!(url.contains("state=test-state"));
1074 }
1075
1076 #[test]
1077 fn parse_jwt_claims_extracts_openai_claims() {
1078 let claims = parse_jwt_claims(
1079 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1080 )
1081 .expect("claims");
1082 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1083 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1084 assert_eq!(claims.plan.as_deref(), Some("plus"));
1085 }
1086
1087 #[test]
1088 fn session_refresh_due_uses_expiry_and_age() {
1089 let mut session = sample_session();
1090 let now = now_secs();
1091 session.obtained_at = now;
1092 session.refreshed_at = now;
1093 session.expires_at = Some(now + 3600);
1094 assert!(!session.is_refresh_due());
1095 session.expires_at = Some(now);
1096 assert!(session.is_refresh_due());
1097 }
1098
1099 #[tokio::test]
1100 #[serial]
1101 async fn external_auth_handle_refreshes_without_persisting_session() {
1102 let _guard = TestAuthDirGuard::new();
1103 let mut session = sample_session();
1104 session.openai_api_key.clear();
1105 session.expires_at = Some(now_secs().saturating_sub(1));
1106 let handle =
1107 OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1108
1109 assert!(handle.using_external_tokens());
1110 assert!(
1111 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1112 .expect("load session")
1113 .is_none()
1114 );
1115
1116 handle.force_refresh().await.expect("force refresh");
1117
1118 assert_eq!(
1119 handle.current_api_key().expect("current api key"),
1120 "oauth-access-refreshed"
1121 );
1122 assert!(
1123 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1124 .expect("load session")
1125 .is_none()
1126 );
1127 }
1128
1129 struct CountingExternalRefresher {
1130 calls: Arc<Mutex<usize>>,
1131 }
1132
1133 #[async_trait]
1134 impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1135 async fn refresh_session(
1136 &self,
1137 current: &OpenAIChatGptSession,
1138 ) -> Result<OpenAIChatGptSession> {
1139 let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1140 *calls += 1;
1141 drop(calls);
1142
1143 let mut refreshed = current.clone();
1144 refreshed.access_token = "oauth-access-refreshed".to_string();
1145 refreshed.refreshed_at = now_secs();
1146 refreshed.expires_at = Some(now_secs() + 3600);
1147 Ok(refreshed)
1148 }
1149 }
1150
1151 #[tokio::test]
1152 async fn refresh_if_needed_serializes_external_refreshes() {
1153 let mut session = sample_session();
1154 session.openai_api_key.clear();
1155 session.expires_at = Some(now_secs().saturating_sub(1));
1156 let calls = Arc::new(Mutex::new(0usize));
1157 let handle = OpenAIChatGptAuthHandle::new_external(
1158 session,
1159 true,
1160 Arc::new(CountingExternalRefresher {
1161 calls: Arc::clone(&calls),
1162 }),
1163 );
1164
1165 let first = handle.clone();
1166 let second = handle.clone();
1167 let (first_result, second_result) =
1168 tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1169
1170 first_result.expect("first refresh should succeed");
1171 second_result.expect("second refresh should succeed");
1172 assert_eq!(
1173 *calls.lock().expect("refresh calls mutex should lock"),
1174 1,
1175 "concurrent refresh_if_needed calls should share one refresh"
1176 );
1177 assert_eq!(
1178 handle.current_api_key().expect("current api key"),
1179 "oauth-access-refreshed"
1180 );
1181 }
1182
1183 #[test]
1184 #[serial]
1185 fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1186 let _guard = TestAuthDirGuard::new();
1187 let session = sample_session();
1188 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1189 .expect("save session");
1190 let resolved = resolve_openai_auth(
1191 &OpenAIAuthConfig::default(),
1192 AuthCredentialsStoreMode::File,
1193 Some("api-key".to_string()),
1194 )
1195 .expect("resolved auth");
1196 assert!(resolved.using_chatgpt());
1197 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1198 .expect("clear session");
1199 }
1200
1201 #[test]
1202 #[serial]
1203 #[cfg(unix)]
1204 fn file_storage_uses_private_permissions() {
1205 use std::fs;
1206 use std::os::unix::fs::PermissionsExt;
1207
1208 let _guard = TestAuthDirGuard::new();
1209 let session = sample_session();
1210
1211 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1212 .expect("save session");
1213
1214 let metadata =
1215 fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1216 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1217 }
1218
1219 #[test]
1220 #[serial]
1221 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1222 let _guard = TestAuthDirGuard::new();
1223 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1224 .expect("clear session");
1225 let resolved = resolve_openai_auth(
1226 &OpenAIAuthConfig::default(),
1227 AuthCredentialsStoreMode::File,
1228 Some("api-key".to_string()),
1229 )
1230 .expect("resolved auth");
1231 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1232 }
1233
1234 #[test]
1235 #[serial]
1236 fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1237 let _guard = TestAuthDirGuard::new();
1238 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1239 .expect("clear session");
1240 let error = resolve_openai_auth(
1241 &OpenAIAuthConfig::default(),
1242 AuthCredentialsStoreMode::File,
1243 Some(" ".to_string()),
1244 )
1245 .expect_err("blank api key should fail");
1246 assert!(error.to_string().contains("OpenAI API key not found"));
1247 }
1248
1249 #[test]
1250 #[serial]
1251 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1252 let _guard = TestAuthDirGuard::new();
1253 let session = sample_session();
1254 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1255 .expect("save session");
1256 let resolved = resolve_openai_auth(
1257 &OpenAIAuthConfig {
1258 preferred_method: OpenAIPreferredMethod::ApiKey,
1259 ..OpenAIAuthConfig::default()
1260 },
1261 AuthCredentialsStoreMode::File,
1262 Some("api-key".to_string()),
1263 )
1264 .expect("resolved auth");
1265 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1266 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1267 .expect("clear session");
1268 }
1269
1270 #[test]
1271 #[serial]
1272 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1273 let _guard = TestAuthDirGuard::new();
1274 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1275 .expect("clear session");
1276 let error = resolve_openai_auth(
1277 &OpenAIAuthConfig {
1278 preferred_method: OpenAIPreferredMethod::Chatgpt,
1279 ..OpenAIAuthConfig::default()
1280 },
1281 AuthCredentialsStoreMode::File,
1282 Some("api-key".to_string()),
1283 )
1284 .expect_err("chatgpt mode should require a stored session");
1285 assert!(error.to_string().contains("vtcode login openai"));
1286 }
1287
1288 #[test]
1289 #[serial]
1290 fn summarize_openai_credentials_reports_dual_source_notice() {
1291 let _guard = TestAuthDirGuard::new();
1292 let session = sample_session();
1293 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1294 .expect("save session");
1295 let overview = summarize_openai_credentials(
1296 &OpenAIAuthConfig::default(),
1297 AuthCredentialsStoreMode::File,
1298 Some("api-key".to_string()),
1299 )
1300 .expect("overview");
1301 assert_eq!(
1302 overview.active_source,
1303 Some(OpenAIResolvedAuthSource::ChatGpt)
1304 );
1305 assert!(overview.notice.is_some());
1306 assert!(overview.recommendation.is_some());
1307 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1308 .expect("clear session");
1309 }
1310
1311 #[test]
1312 #[serial]
1313 fn summarize_openai_credentials_respects_api_key_preference() {
1314 let _guard = TestAuthDirGuard::new();
1315 let session = sample_session();
1316 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1317 .expect("save session");
1318 let overview = summarize_openai_credentials(
1319 &OpenAIAuthConfig {
1320 preferred_method: OpenAIPreferredMethod::ApiKey,
1321 ..OpenAIAuthConfig::default()
1322 },
1323 AuthCredentialsStoreMode::File,
1324 Some("api-key".to_string()),
1325 )
1326 .expect("overview");
1327 assert_eq!(
1328 overview.active_source,
1329 Some(OpenAIResolvedAuthSource::ApiKey)
1330 );
1331 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1332 .expect("clear session");
1333 }
1334
1335 #[test]
1336 fn encrypted_file_round_trip_restores_session() {
1337 let session = sample_session();
1338 let encrypted = encrypt_session(&session).expect("encrypt");
1339 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1340 assert_eq!(decrypted.account_id, session.account_id);
1341 assert_eq!(decrypted.email, session.email);
1342 assert_eq!(decrypted.plan, session.plan);
1343 }
1344
1345 #[test]
1346 #[serial]
1347 fn default_loader_falls_back_to_file_session() {
1348 let _guard = TestAuthDirGuard::new();
1349 let session = sample_session();
1350 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1351 .expect("save session");
1352
1353 let loaded = load_openai_chatgpt_session()
1354 .expect("load session")
1355 .expect("stored session should be found");
1356
1357 assert_eq!(loaded.account_id, session.account_id);
1358 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1359 .expect("clear session");
1360 }
1361
1362 #[test]
1363 #[serial]
1364 fn keyring_mode_loader_falls_back_to_file_session() {
1365 let _guard = TestAuthDirGuard::new();
1366 let session = sample_session();
1367 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1368 .expect("save session");
1369
1370 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1371 .expect("load session")
1372 .expect("stored session should be found");
1373
1374 assert_eq!(loaded.email, session.email);
1375 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1376 .expect("clear session");
1377 }
1378
1379 #[test]
1380 #[serial]
1381 fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1382 let _guard = TestAuthDirGuard::new();
1383 let session = sample_session();
1384 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1385 .expect("save file session");
1386
1387 if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring)
1388 .is_err()
1389 {
1390 clear_openai_chatgpt_session().expect("clear session");
1391 assert!(
1392 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1393 .expect("load file session")
1394 .is_none()
1395 );
1396 return;
1397 }
1398
1399 clear_openai_chatgpt_session().expect("clear session");
1400 assert!(
1401 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1402 .expect("load file session")
1403 .is_none()
1404 );
1405 assert!(
1406 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1407 .expect("load keyring session")
1408 .is_none()
1409 );
1410 }
1411
1412 #[test]
1413 fn active_api_bearer_token_falls_back_to_access_token() {
1414 let mut session = sample_session();
1415 session.openai_api_key.clear();
1416
1417 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1418 }
1419
1420 #[test]
1421 fn parse_manual_callback_input_accepts_full_redirect_url() {
1422 let code = parse_openai_chatgpt_manual_callback_input(
1423 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1424 "test-state",
1425 )
1426 .expect("manual input should parse");
1427 assert_eq!(code, "auth-code");
1428 }
1429
1430 #[test]
1431 fn parse_manual_callback_input_accepts_query_string() {
1432 let code = parse_openai_chatgpt_manual_callback_input(
1433 "code=auth-code&state=test-state",
1434 "test-state",
1435 )
1436 .expect("manual input should parse");
1437 assert_eq!(code, "auth-code");
1438 }
1439
1440 #[test]
1441 fn parse_manual_callback_input_rejects_bare_code() {
1442 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1443 .expect_err("bare code should be rejected");
1444 assert!(
1445 error
1446 .to_string()
1447 .contains("full redirect url or query string")
1448 );
1449 }
1450
1451 #[test]
1452 fn parse_manual_callback_input_rejects_state_mismatch() {
1453 let error = parse_openai_chatgpt_manual_callback_input(
1454 "code=auth-code&state=wrong-state",
1455 "test-state",
1456 )
1457 .expect_err("state mismatch should fail");
1458 assert!(error.to_string().contains("state mismatch"));
1459 }
1460
1461 #[tokio::test]
1462 #[serial]
1463 async fn refresh_lock_serializes_parallel_acquisition() {
1464 let _guard = TestAuthDirGuard::new();
1465 let first = tokio::spawn(async {
1466 let _lock = acquire_refresh_lock().await.expect("first lock");
1467 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1468 });
1469 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1470
1471 let start = std::time::Instant::now();
1472 let second = tokio::spawn(async {
1473 let _lock = acquire_refresh_lock().await.expect("second lock");
1474 });
1475
1476 first.await.expect("first task");
1477 second.await.expect("second task");
1478 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1479 }
1480}