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