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;
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 fs::write(&path, serde_json::to_vec_pretty(&encrypted)?)
869 .context("failed to persist openai session file")?;
870 #[cfg(unix)]
871 {
872 use std::os::unix::fs::PermissionsExt;
873 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
874 .context("failed to set openai session file permissions")?;
875 }
876 Ok(())
877}
878
879fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
880 let path = get_session_path()?;
881 let data = match fs::read(path) {
882 Ok(data) => data,
883 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
884 Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
885 };
886
887 let encrypted: EncryptedSession =
888 serde_json::from_slice(&data).context("failed to decode openai session file")?;
889 Ok(Some(decrypt_session(&encrypted)?))
890}
891
892fn clear_session_from_file() -> Result<()> {
893 let path = get_session_path()?;
894 match fs::remove_file(path) {
895 Ok(()) => Ok(()),
896 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
897 Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
898 }
899}
900
901fn get_session_path() -> Result<PathBuf> {
902 Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
903}
904
905#[derive(Debug, Serialize, Deserialize)]
906struct EncryptedSession {
907 nonce: String,
908 ciphertext: String,
909 version: u8,
910}
911
912fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
913 let key = derive_encryption_key()?;
914 let rng = SystemRandom::new();
915 let mut nonce_bytes = [0u8; NONCE_LEN];
916 rng.fill(&mut nonce_bytes)
917 .map_err(|_| anyhow!("failed to generate nonce"))?;
918
919 let mut ciphertext =
920 serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
921 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
922 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
923 .map_err(|_| anyhow!("failed to encrypt openai session"))?;
924
925 Ok(EncryptedSession {
926 nonce: STANDARD.encode(nonce_bytes),
927 ciphertext: STANDARD.encode(ciphertext),
928 version: 1,
929 })
930}
931
932fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
933 if encrypted.version != 1 {
934 bail!("unsupported openai session encryption format");
935 }
936
937 let nonce_bytes = STANDARD
938 .decode(&encrypted.nonce)
939 .context("failed to decode openai session nonce")?;
940 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
941 .try_into()
942 .map_err(|_| anyhow!("invalid openai session nonce length"))?;
943 let mut ciphertext = STANDARD
944 .decode(&encrypted.ciphertext)
945 .context("failed to decode openai session ciphertext")?;
946
947 let key = derive_encryption_key()?;
948 let plaintext = key
949 .open_in_place(
950 Nonce::assume_unique_for_key(nonce_array),
951 Aad::empty(),
952 &mut ciphertext,
953 )
954 .map_err(|_| anyhow!("failed to decrypt openai session"))?;
955 serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
956}
957
958fn derive_encryption_key() -> Result<LessSafeKey> {
959 use ring::digest::{SHA256, digest};
960
961 let mut key_material = Vec::new();
962 if let Ok(hostname) = hostname::get() {
963 key_material.extend_from_slice(hostname.as_encoded_bytes());
964 }
965
966 #[cfg(unix)]
967 {
968 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
969 }
970 #[cfg(not(unix))]
971 {
972 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
973 key_material.extend_from_slice(user.as_bytes());
974 }
975 }
976
977 key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
978 let hash = digest(&SHA256, &key_material);
979 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
980 .try_into()
981 .context("openai session encryption key was too short")?;
982 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
983 .map_err(|_| anyhow!("invalid openai session encryption key"))?;
984 Ok(LessSafeKey::new(unbound))
985}
986
987fn now_secs() -> u64 {
988 std::time::SystemTime::now()
989 .duration_since(std::time::UNIX_EPOCH)
990 .map(|duration| duration.as_secs())
991 .unwrap_or(0)
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997 use assert_fs::TempDir;
998 use serial_test::serial;
999 use std::sync::Arc;
1000
1001 struct ExternalRefresher;
1002
1003 #[async_trait]
1004 impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1005 async fn refresh_session(
1006 &self,
1007 current: &OpenAIChatGptSession,
1008 ) -> Result<OpenAIChatGptSession> {
1009 let mut refreshed = current.clone();
1010 refreshed.access_token = "oauth-access-refreshed".to_string();
1011 refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1012 refreshed.expires_at = Some(now_secs() + 3600);
1013 Ok(refreshed)
1014 }
1015 }
1016
1017 struct TestAuthDirGuard {
1018 temp_dir: Option<TempDir>,
1019 previous: Option<PathBuf>,
1020 }
1021
1022 impl TestAuthDirGuard {
1023 fn new() -> Self {
1024 let temp_dir = TempDir::new().expect("create temp auth dir");
1025 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1026 .expect("read auth dir override");
1027 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1028 temp_dir.path().to_path_buf(),
1029 ))
1030 .expect("set temp auth dir override");
1031 Self {
1032 temp_dir: Some(temp_dir),
1033 previous,
1034 }
1035 }
1036 }
1037
1038 impl Drop for TestAuthDirGuard {
1039 fn drop(&mut self) {
1040 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1041 .expect("restore auth dir override");
1042 if let Some(temp_dir) = self.temp_dir.take() {
1043 temp_dir.close().expect("remove temp auth dir");
1044 }
1045 }
1046 }
1047
1048 fn sample_session() -> OpenAIChatGptSession {
1049 OpenAIChatGptSession {
1050 openai_api_key: "api-key".to_string(),
1051 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1052 access_token: "oauth-access".to_string(),
1053 refresh_token: "refresh-token".to_string(),
1054 account_id: Some("acc_123".to_string()),
1055 email: Some("test@example.com".to_string()),
1056 plan: Some("plus".to_string()),
1057 obtained_at: 10,
1058 refreshed_at: 10,
1059 expires_at: Some(now_secs() + 3600),
1060 }
1061 }
1062
1063 #[test]
1064 fn auth_url_contains_expected_openai_parameters() {
1065 let challenge = PkceChallenge {
1066 code_verifier: "verifier".to_string(),
1067 code_challenge: "challenge".to_string(),
1068 code_challenge_method: "S256".to_string(),
1069 };
1070
1071 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1072 assert!(url.starts_with(OPENAI_AUTH_URL));
1073 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1074 assert!(url.contains("code_challenge=challenge"));
1075 assert!(url.contains("codex_cli_simplified_flow=true"));
1076 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1077 assert!(url.contains("state=test-state"));
1078 }
1079
1080 #[test]
1081 fn parse_jwt_claims_extracts_openai_claims() {
1082 let claims = parse_jwt_claims(
1083 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1084 )
1085 .expect("claims");
1086 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1087 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1088 assert_eq!(claims.plan.as_deref(), Some("plus"));
1089 }
1090
1091 #[test]
1092 fn session_refresh_due_uses_expiry_and_age() {
1093 let mut session = sample_session();
1094 let now = now_secs();
1095 session.obtained_at = now;
1096 session.refreshed_at = now;
1097 session.expires_at = Some(now + 3600);
1098 assert!(!session.is_refresh_due());
1099 session.expires_at = Some(now);
1100 assert!(session.is_refresh_due());
1101 }
1102
1103 #[tokio::test]
1104 #[serial]
1105 async fn external_auth_handle_refreshes_without_persisting_session() {
1106 let _guard = TestAuthDirGuard::new();
1107 let mut session = sample_session();
1108 session.openai_api_key.clear();
1109 session.expires_at = Some(now_secs().saturating_sub(1));
1110 let handle =
1111 OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1112
1113 assert!(handle.using_external_tokens());
1114 assert!(
1115 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1116 .expect("load session")
1117 .is_none()
1118 );
1119
1120 handle.force_refresh().await.expect("force refresh");
1121
1122 assert_eq!(
1123 handle.current_api_key().expect("current api key"),
1124 "oauth-access-refreshed"
1125 );
1126 assert!(
1127 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1128 .expect("load session")
1129 .is_none()
1130 );
1131 }
1132
1133 struct CountingExternalRefresher {
1134 calls: Arc<Mutex<usize>>,
1135 }
1136
1137 #[async_trait]
1138 impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1139 async fn refresh_session(
1140 &self,
1141 current: &OpenAIChatGptSession,
1142 ) -> Result<OpenAIChatGptSession> {
1143 let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1144 *calls += 1;
1145 drop(calls);
1146
1147 let mut refreshed = current.clone();
1148 refreshed.access_token = "oauth-access-refreshed".to_string();
1149 refreshed.refreshed_at = now_secs();
1150 refreshed.expires_at = Some(now_secs() + 3600);
1151 Ok(refreshed)
1152 }
1153 }
1154
1155 #[tokio::test]
1156 async fn refresh_if_needed_serializes_external_refreshes() {
1157 let mut session = sample_session();
1158 session.openai_api_key.clear();
1159 session.expires_at = Some(now_secs().saturating_sub(1));
1160 let calls = Arc::new(Mutex::new(0usize));
1161 let handle = OpenAIChatGptAuthHandle::new_external(
1162 session,
1163 true,
1164 Arc::new(CountingExternalRefresher {
1165 calls: Arc::clone(&calls),
1166 }),
1167 );
1168
1169 let first = handle.clone();
1170 let second = handle.clone();
1171 let (first_result, second_result) =
1172 tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1173
1174 first_result.expect("first refresh should succeed");
1175 second_result.expect("second refresh should succeed");
1176 assert_eq!(
1177 *calls.lock().expect("refresh calls mutex should lock"),
1178 1,
1179 "concurrent refresh_if_needed calls should share one refresh"
1180 );
1181 assert_eq!(
1182 handle.current_api_key().expect("current api key"),
1183 "oauth-access-refreshed"
1184 );
1185 }
1186
1187 #[test]
1188 #[serial]
1189 fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1190 let _guard = TestAuthDirGuard::new();
1191 let session = sample_session();
1192 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1193 .expect("save session");
1194 let resolved = resolve_openai_auth(
1195 &OpenAIAuthConfig::default(),
1196 AuthCredentialsStoreMode::File,
1197 Some("api-key".to_string()),
1198 )
1199 .expect("resolved auth");
1200 assert!(resolved.using_chatgpt());
1201 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1202 .expect("clear session");
1203 }
1204
1205 #[test]
1206 #[serial]
1207 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1208 let _guard = TestAuthDirGuard::new();
1209 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1210 .expect("clear session");
1211 let resolved = resolve_openai_auth(
1212 &OpenAIAuthConfig::default(),
1213 AuthCredentialsStoreMode::File,
1214 Some("api-key".to_string()),
1215 )
1216 .expect("resolved auth");
1217 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1218 }
1219
1220 #[test]
1221 #[serial]
1222 fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1223 let _guard = TestAuthDirGuard::new();
1224 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1225 .expect("clear session");
1226 let error = resolve_openai_auth(
1227 &OpenAIAuthConfig::default(),
1228 AuthCredentialsStoreMode::File,
1229 Some(" ".to_string()),
1230 )
1231 .expect_err("blank api key should fail");
1232 assert!(error.to_string().contains("OpenAI API key not found"));
1233 }
1234
1235 #[test]
1236 #[serial]
1237 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1238 let _guard = TestAuthDirGuard::new();
1239 let session = sample_session();
1240 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1241 .expect("save session");
1242 let resolved = resolve_openai_auth(
1243 &OpenAIAuthConfig {
1244 preferred_method: OpenAIPreferredMethod::ApiKey,
1245 ..OpenAIAuthConfig::default()
1246 },
1247 AuthCredentialsStoreMode::File,
1248 Some("api-key".to_string()),
1249 )
1250 .expect("resolved auth");
1251 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1252 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1253 .expect("clear session");
1254 }
1255
1256 #[test]
1257 #[serial]
1258 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1259 let _guard = TestAuthDirGuard::new();
1260 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1261 .expect("clear session");
1262 let error = resolve_openai_auth(
1263 &OpenAIAuthConfig {
1264 preferred_method: OpenAIPreferredMethod::Chatgpt,
1265 ..OpenAIAuthConfig::default()
1266 },
1267 AuthCredentialsStoreMode::File,
1268 Some("api-key".to_string()),
1269 )
1270 .expect_err("chatgpt mode should require a stored session");
1271 assert!(error.to_string().contains("vtcode login openai"));
1272 }
1273
1274 #[test]
1275 #[serial]
1276 fn summarize_openai_credentials_reports_dual_source_notice() {
1277 let _guard = TestAuthDirGuard::new();
1278 let session = sample_session();
1279 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1280 .expect("save session");
1281 let overview = summarize_openai_credentials(
1282 &OpenAIAuthConfig::default(),
1283 AuthCredentialsStoreMode::File,
1284 Some("api-key".to_string()),
1285 )
1286 .expect("overview");
1287 assert_eq!(
1288 overview.active_source,
1289 Some(OpenAIResolvedAuthSource::ChatGpt)
1290 );
1291 assert!(overview.notice.is_some());
1292 assert!(overview.recommendation.is_some());
1293 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1294 .expect("clear session");
1295 }
1296
1297 #[test]
1298 #[serial]
1299 fn summarize_openai_credentials_respects_api_key_preference() {
1300 let _guard = TestAuthDirGuard::new();
1301 let session = sample_session();
1302 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1303 .expect("save session");
1304 let overview = summarize_openai_credentials(
1305 &OpenAIAuthConfig {
1306 preferred_method: OpenAIPreferredMethod::ApiKey,
1307 ..OpenAIAuthConfig::default()
1308 },
1309 AuthCredentialsStoreMode::File,
1310 Some("api-key".to_string()),
1311 )
1312 .expect("overview");
1313 assert_eq!(
1314 overview.active_source,
1315 Some(OpenAIResolvedAuthSource::ApiKey)
1316 );
1317 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1318 .expect("clear session");
1319 }
1320
1321 #[test]
1322 fn encrypted_file_round_trip_restores_session() {
1323 let session = sample_session();
1324 let encrypted = encrypt_session(&session).expect("encrypt");
1325 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1326 assert_eq!(decrypted.account_id, session.account_id);
1327 assert_eq!(decrypted.email, session.email);
1328 assert_eq!(decrypted.plan, session.plan);
1329 }
1330
1331 #[test]
1332 #[serial]
1333 fn default_loader_falls_back_to_file_session() {
1334 let _guard = TestAuthDirGuard::new();
1335 let session = sample_session();
1336 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1337 .expect("save session");
1338
1339 let loaded = load_openai_chatgpt_session()
1340 .expect("load session")
1341 .expect("stored session should be found");
1342
1343 assert_eq!(loaded.account_id, session.account_id);
1344 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1345 .expect("clear session");
1346 }
1347
1348 #[test]
1349 #[serial]
1350 fn keyring_mode_loader_falls_back_to_file_session() {
1351 let _guard = TestAuthDirGuard::new();
1352 let session = sample_session();
1353 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1354 .expect("save session");
1355
1356 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1357 .expect("load session")
1358 .expect("stored session should be found");
1359
1360 assert_eq!(loaded.email, session.email);
1361 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1362 .expect("clear session");
1363 }
1364
1365 #[test]
1366 #[serial]
1367 fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1368 let _guard = TestAuthDirGuard::new();
1369 let session = sample_session();
1370 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1371 .expect("save file session");
1372
1373 if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring)
1374 .is_err()
1375 {
1376 clear_openai_chatgpt_session().expect("clear session");
1377 assert!(
1378 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1379 .expect("load file session")
1380 .is_none()
1381 );
1382 return;
1383 }
1384
1385 clear_openai_chatgpt_session().expect("clear session");
1386 assert!(
1387 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1388 .expect("load file session")
1389 .is_none()
1390 );
1391 assert!(
1392 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1393 .expect("load keyring session")
1394 .is_none()
1395 );
1396 }
1397
1398 #[test]
1399 fn active_api_bearer_token_falls_back_to_access_token() {
1400 let mut session = sample_session();
1401 session.openai_api_key.clear();
1402
1403 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1404 }
1405
1406 #[test]
1407 fn parse_manual_callback_input_accepts_full_redirect_url() {
1408 let code = parse_openai_chatgpt_manual_callback_input(
1409 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1410 "test-state",
1411 )
1412 .expect("manual input should parse");
1413 assert_eq!(code, "auth-code");
1414 }
1415
1416 #[test]
1417 fn parse_manual_callback_input_accepts_query_string() {
1418 let code = parse_openai_chatgpt_manual_callback_input(
1419 "code=auth-code&state=test-state",
1420 "test-state",
1421 )
1422 .expect("manual input should parse");
1423 assert_eq!(code, "auth-code");
1424 }
1425
1426 #[test]
1427 fn parse_manual_callback_input_rejects_bare_code() {
1428 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1429 .expect_err("bare code should be rejected");
1430 assert!(
1431 error
1432 .to_string()
1433 .contains("full redirect url or query string")
1434 );
1435 }
1436
1437 #[test]
1438 fn parse_manual_callback_input_rejects_state_mismatch() {
1439 let error = parse_openai_chatgpt_manual_callback_input(
1440 "code=auth-code&state=wrong-state",
1441 "test-state",
1442 )
1443 .expect_err("state mismatch should fail");
1444 assert!(error.to_string().contains("state mismatch"));
1445 }
1446
1447 #[tokio::test]
1448 #[serial]
1449 async fn refresh_lock_serializes_parallel_acquisition() {
1450 let _guard = TestAuthDirGuard::new();
1451 let first = tokio::spawn(async {
1452 let _lock = acquire_refresh_lock().await.expect("first lock");
1453 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1454 });
1455 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1456
1457 let start = std::time::Instant::now();
1458 let second = tokio::spawn(async {
1459 let _lock = acquire_refresh_lock().await.expect("second lock");
1460 });
1461
1462 first.await.expect("first task");
1463 second.await.expect("second task");
1464 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1465 }
1466}