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