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