Skip to main content

vtcode_auth/
openai_chatgpt_oauth.rs

1//! OpenAI ChatGPT subscription OAuth flow and secure session storage.
2//!
3//! This module mirrors the Codex CLI login flow closely enough for VT Code:
4//! - OAuth authorization-code flow with PKCE
5//! - refresh-token exchange
6//! - token exchange for an OpenAI API-key-style bearer token
7//! - secure storage in keyring or encrypted file storage
8
9use anyhow::{Context, Result, anyhow, bail};
10use async_trait::async_trait;
11use base64::{Engine, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD};
12use fs2::FileExt;
13use reqwest::Client;
14use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
15use ring::rand::{SecureRandom, SystemRandom};
16use serde::{Deserialize, Serialize};
17use std::fmt;
18use std::fs;
19use std::fs::OpenOptions;
20use std::path::PathBuf;
21use std::sync::{Arc, Mutex};
22use tokio::sync::Mutex as AsyncMutex;
23
24use crate::storage_paths::{auth_storage_dir, write_private_file};
25use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
26
27pub use super::credentials::AuthCredentialsStoreMode;
28use super::pkce::PkceChallenge;
29
30const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
31const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
32const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
33const OPENAI_ORIGINATOR: &str = "codex_cli_rs";
34const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
35const OPENAI_STORAGE_SERVICE: &str = "vtcode";
36const OPENAI_STORAGE_USER: &str = "openai_chatgpt_session";
37const OPENAI_SESSION_FILE: &str = "openai_chatgpt.json";
38const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
39const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
40const REFRESH_SKEW_SECS: u64 = 60;
41
42/// Stored OpenAI ChatGPT subscription session.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct OpenAIChatGptSession {
45    /// Exchanged OpenAI bearer token used for normal API calls when available.
46    /// If unavailable, VT Code falls back to the OAuth access token.
47    pub openai_api_key: String,
48    /// OAuth ID token from the sign-in flow.
49    pub id_token: String,
50    /// OAuth access token from the sign-in flow.
51    pub access_token: String,
52    /// Refresh token used to renew the session.
53    pub refresh_token: String,
54    /// ChatGPT workspace/account identifier, if present.
55    pub account_id: Option<String>,
56    /// Account email, if present.
57    pub email: Option<String>,
58    /// ChatGPT plan type, if present.
59    pub plan: Option<String>,
60    /// When the session was originally created.
61    pub obtained_at: u64,
62    /// When the OAuth/API-key exchange was last refreshed.
63    pub refreshed_at: u64,
64    /// Access-token expiry, if supplied by the authority.
65    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/// Host-provided refresher for externally managed ChatGPT auth tokens.
81#[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/// Runtime auth state shared by OpenAI provider instances.
110#[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/// OpenAI auth resolution chosen for the current runtime.
222#[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/// Generic auth status reused by slash auth/status output.
278#[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
288/// Build the OpenAI ChatGPT OAuth authorization URL.
289pub 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
361/// Exchange an authorization code for OAuth tokens.
362pub 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
392/// Resolve the active OpenAI auth source for the current configuration.
393pub 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(&current, session)
500    {
501        return Ok(current);
502    }
503    refresh_openai_chatgpt_session_without_lock(&session.refresh_token, storage_mode).await
504}
505
506async fn refresh_openai_chatgpt_session_without_lock(
507    refresh_token: &str,
508    storage_mode: AuthCredentialsStoreMode,
509) -> Result<OpenAIChatGptSession> {
510    let response = Client::new()
511        .post(OPENAI_TOKEN_URL)
512        .header("Content-Type", "application/x-www-form-urlencoded")
513        .body(format!(
514            "grant_type=refresh_token&client_id={}&refresh_token={}",
515            urlencoding::encode(OPENAI_CLIENT_ID),
516            urlencoding::encode(refresh_token),
517        ))
518        .send()
519        .await
520        .context("failed to refresh openai chatgpt token")?;
521    response
522        .error_for_status_ref()
523        .map_err(classify_refresh_error)?;
524    let token_response: OpenAITokenResponse = response
525        .json()
526        .await
527        .context("failed to parse openai refresh response")?;
528
529    let session = build_session_from_token_response(token_response).await?;
530    save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
531    Ok(session)
532}
533
534async fn build_session_from_token_response(
535    token_response: OpenAITokenResponse,
536) -> Result<OpenAIChatGptSession> {
537    let id_claims = parse_jwt_claims(&token_response.id_token)?;
538    let access_claims = parse_jwt_claims(&token_response.access_token).ok();
539    let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
540        Ok(api_key) => api_key,
541        Err(err) => {
542            tracing::warn!(
543                "openai api-key exchange unavailable, falling back to oauth access token: {err}"
544            );
545            String::new()
546        }
547    };
548    let now = now_secs();
549    Ok(OpenAIChatGptSession {
550        openai_api_key: api_key,
551        id_token: token_response.id_token,
552        access_token: token_response.access_token,
553        refresh_token: token_response.refresh_token,
554        account_id: access_claims
555            .as_ref()
556            .and_then(|claims| claims.account_id.clone())
557            .or(id_claims.account_id),
558        email: id_claims.email.or_else(|| {
559            access_claims
560                .as_ref()
561                .and_then(|claims| claims.email.clone())
562        }),
563        plan: access_claims
564            .as_ref()
565            .and_then(|claims| claims.plan.clone())
566            .or(id_claims.plan),
567        obtained_at: now,
568        refreshed_at: now,
569        expires_at: token_response
570            .expires_in
571            .map(|secs| now.saturating_add(secs)),
572    })
573}
574
575async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
576    #[derive(Deserialize)]
577    struct ExchangeResponse {
578        access_token: String,
579    }
580
581    let exchange: ExchangeResponse = Client::new()
582        .post(OPENAI_TOKEN_URL)
583        .header("Content-Type", "application/x-www-form-urlencoded")
584        .body(format!(
585            "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
586            urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
587            urlencoding::encode(OPENAI_CLIENT_ID),
588            urlencoding::encode("openai-api-key"),
589            urlencoding::encode(id_token),
590            urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
591        ))
592        .send()
593        .await
594        .context("failed to exchange openai id token for api key")?
595        .error_for_status()
596        .context("openai api-key exchange failed")?
597        .json()
598        .await
599        .context("failed to parse openai api-key exchange response")?;
600
601    Ok(exchange.access_token)
602}
603
604#[derive(Debug, Deserialize)]
605struct OpenAITokenResponse {
606    id_token: String,
607    access_token: String,
608    refresh_token: String,
609    #[serde(default)]
610    expires_in: Option<u64>,
611}
612
613#[derive(Debug, Deserialize)]
614struct IdTokenClaims {
615    #[serde(default)]
616    email: Option<String>,
617    #[serde(rename = "https://api.openai.com/profile", default)]
618    profile: Option<ProfileClaims>,
619    #[serde(rename = "https://api.openai.com/auth", default)]
620    auth: Option<AuthClaims>,
621}
622
623#[derive(Debug, Deserialize)]
624struct ProfileClaims {
625    #[serde(default)]
626    email: Option<String>,
627}
628
629#[derive(Debug, Deserialize)]
630struct AuthClaims {
631    #[serde(default)]
632    chatgpt_plan_type: Option<String>,
633    #[serde(default)]
634    chatgpt_account_id: Option<String>,
635}
636
637#[derive(Debug)]
638struct ParsedIdTokenClaims {
639    email: Option<String>,
640    account_id: Option<String>,
641    plan: Option<String>,
642}
643
644fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
645    let mut parts = jwt.split('.');
646    let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
647        (Some(header), Some(payload), Some(signature))
648            if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
649        {
650            (header, payload, signature)
651        }
652        _ => bail!("invalid openai id token"),
653    };
654
655    let payload = URL_SAFE_NO_PAD
656        .decode(payload_b64)
657        .context("failed to decode openai id token payload")?;
658    let claims: IdTokenClaims =
659        serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
660
661    Ok(ParsedIdTokenClaims {
662        email: claims
663            .email
664            .or_else(|| claims.profile.and_then(|profile| profile.email)),
665        account_id: claims
666            .auth
667            .as_ref()
668            .and_then(|auth| auth.chatgpt_account_id.clone()),
669        plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
670    })
671}
672
673fn extract_query_value(query: &str, key: &str) -> Option<String> {
674    query
675        .trim_start_matches('?')
676        .split('&')
677        .filter_map(|pair| {
678            let (pair_key, pair_value) = pair.split_once('=')?;
679            (pair_key == key)
680                .then(|| {
681                    urlencoding::decode(pair_value)
682                        .ok()
683                        .map(|value| value.into_owned())
684                })
685                .flatten()
686        })
687        .find(|value| !value.is_empty())
688}
689
690fn session_has_newer_refresh_state(
691    current: &OpenAIChatGptSession,
692    previous: &OpenAIChatGptSession,
693) -> bool {
694    current.refresh_token != previous.refresh_token
695        || current.refreshed_at > previous.refreshed_at
696        || current.obtained_at > previous.obtained_at
697}
698
699struct RefreshLockGuard {
700    file: fs::File,
701}
702
703impl Drop for RefreshLockGuard {
704    fn drop(&mut self) {
705        let _ = FileExt::unlock(&self.file);
706    }
707}
708
709async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
710    let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
711    let file = OpenOptions::new()
712        .create(true)
713        .read(true)
714        .write(true)
715        .truncate(false)
716        .open(&path)
717        .with_context(|| format!("failed to open openai refresh lock {}", path.display()))?;
718    let file = tokio::task::spawn_blocking(move || {
719        file.lock_exclusive()
720            .context("failed to acquire openai refresh lock")?;
721        Ok::<_, anyhow::Error>(file)
722    })
723    .await
724    .context("openai refresh lock task failed")??;
725    Ok(RefreshLockGuard { file })
726}
727
728fn classify_refresh_error(err: reqwest::Error) -> anyhow::Error {
729    let status = err.status();
730    let message = err.to_string();
731    if status.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST)
732        && (message.contains("invalid_grant") || message.contains("refresh_token"))
733    {
734        if let Err(clear_err) = clear_session_from_all_stores() {
735            tracing::warn!(
736                "failed to clear expired openai chatgpt session across all stores: {clear_err}"
737            );
738        }
739        anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
740    } else {
741        anyhow!(message)
742    }
743}
744
745fn clear_session_from_all_stores() -> Result<()> {
746    let mut errors = Vec::new();
747
748    if let Err(err) = clear_session_from_keyring() {
749        errors.push(err.to_string());
750    }
751    if let Err(err) = clear_session_from_file() {
752        errors.push(err.to_string());
753    }
754
755    if errors.is_empty() {
756        Ok(())
757    } else {
758        Err(anyhow!(
759            "failed to clear openai session from all stores: {}",
760            errors.join("; ")
761        ))
762    }
763}
764
765fn save_session_to_keyring(serialized: &str) -> Result<()> {
766    let entry = keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
767        .context("failed to access keyring for openai session")?;
768    entry
769        .set_password(serialized)
770        .context("failed to store openai session in keyring")?;
771    Ok(())
772}
773
774fn persist_session_to_keyring_or_file(
775    session: &OpenAIChatGptSession,
776    serialized: &str,
777) -> Result<()> {
778    match save_session_to_keyring(serialized) {
779        Ok(()) => match load_session_from_keyring_decoded() {
780            Ok(Some(_)) => Ok(()),
781            Ok(None) => {
782                tracing::warn!(
783                    "openai session keyring write did not round-trip; falling back to encrypted file storage"
784                );
785                save_session_to_file(session)
786            }
787            Err(err) => {
788                tracing::warn!(
789                    "openai session keyring verification failed, falling back to encrypted file storage: {err}"
790                );
791                save_session_to_file(session)
792            }
793        },
794        Err(err) => {
795            tracing::warn!(
796                "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
797            );
798            save_session_to_file(session)
799                .context("failed to persist openai session after keyring fallback")
800        }
801    }
802}
803
804fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
805    serde_json::from_str(&serialized).context("failed to decode openai session")
806}
807
808fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
809    load_session_from_keyring()?
810        .map(decode_session_from_keyring)
811        .transpose()
812}
813
814fn load_preferred_openai_chatgpt_session(
815    mode: AuthCredentialsStoreMode,
816) -> Result<Option<OpenAIChatGptSession>> {
817    match mode {
818        AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
819            Ok(Some(session)) => Ok(Some(session)),
820            Ok(None) => load_session_from_file(),
821            Err(err) => {
822                tracing::warn!(
823                    "failed to load openai session from keyring, falling back to encrypted file: {err}"
824                );
825                load_session_from_file()
826            }
827        },
828        AuthCredentialsStoreMode::File => {
829            if let Some(session) = load_session_from_file()? {
830                return Ok(Some(session));
831            }
832            load_session_from_keyring_decoded()
833        }
834        AuthCredentialsStoreMode::Auto => unreachable!(),
835    }
836}
837
838fn load_session_from_keyring() -> Result<Option<String>> {
839    let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
840        Ok(entry) => entry,
841        Err(_) => return Ok(None),
842    };
843
844    match entry.get_password() {
845        Ok(value) => Ok(Some(value)),
846        Err(keyring::Error::NoEntry) => Ok(None),
847        Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
848    }
849}
850
851fn clear_session_from_keyring() -> Result<()> {
852    let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
853        Ok(entry) => entry,
854        Err(_) => return Ok(()),
855    };
856
857    match entry.delete_credential() {
858        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
859        Err(err) => Err(anyhow!(
860            "failed to clear openai session keyring entry: {err}"
861        )),
862    }
863}
864
865fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
866    let encrypted = encrypt_session(session)?;
867    let path = get_session_path()?;
868    let payload = serde_json::to_vec_pretty(&encrypted)?;
869    write_private_file(&path, &payload).context("failed to persist openai session file")?;
870    Ok(())
871}
872
873fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
874    let path = get_session_path()?;
875    let data = match fs::read(path) {
876        Ok(data) => data,
877        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
878        Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
879    };
880
881    let encrypted: EncryptedSession =
882        serde_json::from_slice(&data).context("failed to decode openai session file")?;
883    Ok(Some(decrypt_session(&encrypted)?))
884}
885
886fn clear_session_from_file() -> Result<()> {
887    let path = get_session_path()?;
888    match fs::remove_file(path) {
889        Ok(()) => Ok(()),
890        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
891        Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
892    }
893}
894
895fn get_session_path() -> Result<PathBuf> {
896    Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
897}
898
899#[derive(Debug, Serialize, Deserialize)]
900struct EncryptedSession {
901    nonce: String,
902    ciphertext: String,
903    version: u8,
904}
905
906fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
907    let key = derive_encryption_key()?;
908    let rng = SystemRandom::new();
909    let mut nonce_bytes = [0u8; NONCE_LEN];
910    rng.fill(&mut nonce_bytes)
911        .map_err(|_| anyhow!("failed to generate nonce"))?;
912
913    let mut ciphertext =
914        serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
915    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
916    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
917        .map_err(|_| anyhow!("failed to encrypt openai session"))?;
918
919    Ok(EncryptedSession {
920        nonce: STANDARD.encode(nonce_bytes),
921        ciphertext: STANDARD.encode(ciphertext),
922        version: 1,
923    })
924}
925
926fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
927    if encrypted.version != 1 {
928        bail!("unsupported openai session encryption format");
929    }
930
931    let nonce_bytes = STANDARD
932        .decode(&encrypted.nonce)
933        .context("failed to decode openai session nonce")?;
934    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
935        .try_into()
936        .map_err(|_| anyhow!("invalid openai session nonce length"))?;
937    let mut ciphertext = STANDARD
938        .decode(&encrypted.ciphertext)
939        .context("failed to decode openai session ciphertext")?;
940
941    let key = derive_encryption_key()?;
942    let plaintext = key
943        .open_in_place(
944            Nonce::assume_unique_for_key(nonce_array),
945            Aad::empty(),
946            &mut ciphertext,
947        )
948        .map_err(|_| anyhow!("failed to decrypt openai session"))?;
949    serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
950}
951
952fn derive_encryption_key() -> Result<LessSafeKey> {
953    use ring::digest::{SHA256, digest};
954
955    let mut key_material = Vec::new();
956    if let Ok(hostname) = hostname::get() {
957        key_material.extend_from_slice(hostname.as_encoded_bytes());
958    }
959
960    #[cfg(unix)]
961    {
962        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
963    }
964    #[cfg(not(unix))]
965    {
966        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
967            key_material.extend_from_slice(user.as_bytes());
968        }
969    }
970
971    key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
972    let hash = digest(&SHA256, &key_material);
973    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
974        .try_into()
975        .context("openai session encryption key was too short")?;
976    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
977        .map_err(|_| anyhow!("invalid openai session encryption key"))?;
978    Ok(LessSafeKey::new(unbound))
979}
980
981fn now_secs() -> u64 {
982    std::time::SystemTime::now()
983        .duration_since(std::time::UNIX_EPOCH)
984        .map(|duration| duration.as_secs())
985        .unwrap_or(0)
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use assert_fs::TempDir;
992    use serial_test::serial;
993    use std::sync::Arc;
994
995    struct ExternalRefresher;
996
997    #[async_trait]
998    impl OpenAIChatGptSessionRefresher for ExternalRefresher {
999        async fn refresh_session(
1000            &self,
1001            current: &OpenAIChatGptSession,
1002        ) -> Result<OpenAIChatGptSession> {
1003            let mut refreshed = current.clone();
1004            refreshed.access_token = "oauth-access-refreshed".to_string();
1005            refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1006            refreshed.expires_at = Some(now_secs() + 3600);
1007            Ok(refreshed)
1008        }
1009    }
1010
1011    struct TestAuthDirGuard {
1012        temp_dir: Option<TempDir>,
1013        previous: Option<PathBuf>,
1014    }
1015
1016    impl TestAuthDirGuard {
1017        fn new() -> Self {
1018            let temp_dir = TempDir::new().expect("create temp auth dir");
1019            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1020                .expect("read auth dir override");
1021            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1022                temp_dir.path().to_path_buf(),
1023            ))
1024            .expect("set temp auth dir override");
1025            Self {
1026                temp_dir: Some(temp_dir),
1027                previous,
1028            }
1029        }
1030    }
1031
1032    impl Drop for TestAuthDirGuard {
1033        fn drop(&mut self) {
1034            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1035                .expect("restore auth dir override");
1036            if let Some(temp_dir) = self.temp_dir.take() {
1037                temp_dir.close().expect("remove temp auth dir");
1038            }
1039        }
1040    }
1041
1042    fn sample_session() -> OpenAIChatGptSession {
1043        OpenAIChatGptSession {
1044            openai_api_key: "api-key".to_string(),
1045            id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1046            access_token: "oauth-access".to_string(),
1047            refresh_token: "refresh-token".to_string(),
1048            account_id: Some("acc_123".to_string()),
1049            email: Some("test@example.com".to_string()),
1050            plan: Some("plus".to_string()),
1051            obtained_at: 10,
1052            refreshed_at: 10,
1053            expires_at: Some(now_secs() + 3600),
1054        }
1055    }
1056
1057    #[test]
1058    fn auth_url_contains_expected_openai_parameters() {
1059        let challenge = PkceChallenge {
1060            code_verifier: "verifier".to_string(),
1061            code_challenge: "challenge".to_string(),
1062            code_challenge_method: "S256".to_string(),
1063        };
1064
1065        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1066        assert!(url.starts_with(OPENAI_AUTH_URL));
1067        assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1068        assert!(url.contains("code_challenge=challenge"));
1069        assert!(url.contains("codex_cli_simplified_flow=true"));
1070        assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1071        assert!(url.contains("state=test-state"));
1072    }
1073
1074    #[test]
1075    fn parse_jwt_claims_extracts_openai_claims() {
1076        let claims = parse_jwt_claims(
1077            "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1078        )
1079        .expect("claims");
1080        assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1081        assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1082        assert_eq!(claims.plan.as_deref(), Some("plus"));
1083    }
1084
1085    #[test]
1086    fn session_refresh_due_uses_expiry_and_age() {
1087        let mut session = sample_session();
1088        let now = now_secs();
1089        session.obtained_at = now;
1090        session.refreshed_at = now;
1091        session.expires_at = Some(now + 3600);
1092        assert!(!session.is_refresh_due());
1093        session.expires_at = Some(now);
1094        assert!(session.is_refresh_due());
1095    }
1096
1097    #[tokio::test]
1098    #[serial]
1099    async fn external_auth_handle_refreshes_without_persisting_session() {
1100        let _guard = TestAuthDirGuard::new();
1101        let mut session = sample_session();
1102        session.openai_api_key.clear();
1103        session.expires_at = Some(now_secs().saturating_sub(1));
1104        let handle =
1105            OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1106
1107        assert!(handle.using_external_tokens());
1108        assert!(
1109            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1110                .expect("load session")
1111                .is_none()
1112        );
1113
1114        handle.force_refresh().await.expect("force refresh");
1115
1116        assert_eq!(
1117            handle.current_api_key().expect("current api key"),
1118            "oauth-access-refreshed"
1119        );
1120        assert!(
1121            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1122                .expect("load session")
1123                .is_none()
1124        );
1125    }
1126
1127    struct CountingExternalRefresher {
1128        calls: Arc<Mutex<usize>>,
1129    }
1130
1131    #[async_trait]
1132    impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1133        async fn refresh_session(
1134            &self,
1135            current: &OpenAIChatGptSession,
1136        ) -> Result<OpenAIChatGptSession> {
1137            let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1138            *calls += 1;
1139            drop(calls);
1140
1141            let mut refreshed = current.clone();
1142            refreshed.access_token = "oauth-access-refreshed".to_string();
1143            refreshed.refreshed_at = now_secs();
1144            refreshed.expires_at = Some(now_secs() + 3600);
1145            Ok(refreshed)
1146        }
1147    }
1148
1149    #[tokio::test]
1150    async fn refresh_if_needed_serializes_external_refreshes() {
1151        let mut session = sample_session();
1152        session.openai_api_key.clear();
1153        session.expires_at = Some(now_secs().saturating_sub(1));
1154        let calls = Arc::new(Mutex::new(0usize));
1155        let handle = OpenAIChatGptAuthHandle::new_external(
1156            session,
1157            true,
1158            Arc::new(CountingExternalRefresher {
1159                calls: Arc::clone(&calls),
1160            }),
1161        );
1162
1163        let first = handle.clone();
1164        let second = handle.clone();
1165        let (first_result, second_result) =
1166            tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1167
1168        first_result.expect("first refresh should succeed");
1169        second_result.expect("second refresh should succeed");
1170        assert_eq!(
1171            *calls.lock().expect("refresh calls mutex should lock"),
1172            1,
1173            "concurrent refresh_if_needed calls should share one refresh"
1174        );
1175        assert_eq!(
1176            handle.current_api_key().expect("current api key"),
1177            "oauth-access-refreshed"
1178        );
1179    }
1180
1181    #[test]
1182    #[serial]
1183    fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1184        let _guard = TestAuthDirGuard::new();
1185        let session = sample_session();
1186        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1187            .expect("save session");
1188        let resolved = resolve_openai_auth(
1189            &OpenAIAuthConfig::default(),
1190            AuthCredentialsStoreMode::File,
1191            Some("api-key".to_string()),
1192        )
1193        .expect("resolved auth");
1194        assert!(resolved.using_chatgpt());
1195        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1196            .expect("clear session");
1197    }
1198
1199    #[test]
1200    #[serial]
1201    #[cfg(unix)]
1202    fn file_storage_uses_private_permissions() {
1203        use std::fs;
1204        use std::os::unix::fs::PermissionsExt;
1205
1206        let _guard = TestAuthDirGuard::new();
1207        let session = sample_session();
1208
1209        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1210            .expect("save session");
1211
1212        let metadata =
1213            fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1214        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1215    }
1216
1217    #[test]
1218    #[serial]
1219    fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1220        let _guard = TestAuthDirGuard::new();
1221        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1222            .expect("clear session");
1223        let resolved = resolve_openai_auth(
1224            &OpenAIAuthConfig::default(),
1225            AuthCredentialsStoreMode::File,
1226            Some("api-key".to_string()),
1227        )
1228        .expect("resolved auth");
1229        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1230    }
1231
1232    #[test]
1233    #[serial]
1234    fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1235        let _guard = TestAuthDirGuard::new();
1236        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1237            .expect("clear session");
1238        let error = resolve_openai_auth(
1239            &OpenAIAuthConfig::default(),
1240            AuthCredentialsStoreMode::File,
1241            Some("   ".to_string()),
1242        )
1243        .expect_err("blank api key should fail");
1244        assert!(error.to_string().contains("OpenAI API key not found"));
1245    }
1246
1247    #[test]
1248    #[serial]
1249    fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1250        let _guard = TestAuthDirGuard::new();
1251        let session = sample_session();
1252        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1253            .expect("save session");
1254        let resolved = resolve_openai_auth(
1255            &OpenAIAuthConfig {
1256                preferred_method: OpenAIPreferredMethod::ApiKey,
1257                ..OpenAIAuthConfig::default()
1258            },
1259            AuthCredentialsStoreMode::File,
1260            Some("api-key".to_string()),
1261        )
1262        .expect("resolved auth");
1263        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1264        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1265            .expect("clear session");
1266    }
1267
1268    #[test]
1269    #[serial]
1270    fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1271        let _guard = TestAuthDirGuard::new();
1272        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1273            .expect("clear session");
1274        let error = resolve_openai_auth(
1275            &OpenAIAuthConfig {
1276                preferred_method: OpenAIPreferredMethod::Chatgpt,
1277                ..OpenAIAuthConfig::default()
1278            },
1279            AuthCredentialsStoreMode::File,
1280            Some("api-key".to_string()),
1281        )
1282        .expect_err("chatgpt mode should require a stored session");
1283        assert!(error.to_string().contains("vtcode login openai"));
1284    }
1285
1286    #[test]
1287    #[serial]
1288    fn summarize_openai_credentials_reports_dual_source_notice() {
1289        let _guard = TestAuthDirGuard::new();
1290        let session = sample_session();
1291        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1292            .expect("save session");
1293        let overview = summarize_openai_credentials(
1294            &OpenAIAuthConfig::default(),
1295            AuthCredentialsStoreMode::File,
1296            Some("api-key".to_string()),
1297        )
1298        .expect("overview");
1299        assert_eq!(
1300            overview.active_source,
1301            Some(OpenAIResolvedAuthSource::ChatGpt)
1302        );
1303        assert!(overview.notice.is_some());
1304        assert!(overview.recommendation.is_some());
1305        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1306            .expect("clear session");
1307    }
1308
1309    #[test]
1310    #[serial]
1311    fn summarize_openai_credentials_respects_api_key_preference() {
1312        let _guard = TestAuthDirGuard::new();
1313        let session = sample_session();
1314        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1315            .expect("save session");
1316        let overview = summarize_openai_credentials(
1317            &OpenAIAuthConfig {
1318                preferred_method: OpenAIPreferredMethod::ApiKey,
1319                ..OpenAIAuthConfig::default()
1320            },
1321            AuthCredentialsStoreMode::File,
1322            Some("api-key".to_string()),
1323        )
1324        .expect("overview");
1325        assert_eq!(
1326            overview.active_source,
1327            Some(OpenAIResolvedAuthSource::ApiKey)
1328        );
1329        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1330            .expect("clear session");
1331    }
1332
1333    #[test]
1334    fn encrypted_file_round_trip_restores_session() {
1335        let session = sample_session();
1336        let encrypted = encrypt_session(&session).expect("encrypt");
1337        let decrypted = decrypt_session(&encrypted).expect("decrypt");
1338        assert_eq!(decrypted.account_id, session.account_id);
1339        assert_eq!(decrypted.email, session.email);
1340        assert_eq!(decrypted.plan, session.plan);
1341    }
1342
1343    #[test]
1344    #[serial]
1345    fn default_loader_falls_back_to_file_session() {
1346        let _guard = TestAuthDirGuard::new();
1347        let session = sample_session();
1348        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1349            .expect("save session");
1350
1351        let loaded = load_openai_chatgpt_session()
1352            .expect("load session")
1353            .expect("stored session should be found");
1354
1355        assert_eq!(loaded.account_id, session.account_id);
1356        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1357            .expect("clear session");
1358    }
1359
1360    #[test]
1361    #[serial]
1362    fn keyring_mode_loader_falls_back_to_file_session() {
1363        let _guard = TestAuthDirGuard::new();
1364        let session = sample_session();
1365        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1366            .expect("save session");
1367
1368        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1369            .expect("load session")
1370            .expect("stored session should be found");
1371
1372        assert_eq!(loaded.email, session.email);
1373        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1374            .expect("clear session");
1375    }
1376
1377    #[test]
1378    #[serial]
1379    fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1380        let _guard = TestAuthDirGuard::new();
1381        let session = sample_session();
1382        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1383            .expect("save file session");
1384
1385        if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring)
1386            .is_err()
1387        {
1388            clear_openai_chatgpt_session().expect("clear session");
1389            assert!(
1390                load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1391                    .expect("load file session")
1392                    .is_none()
1393            );
1394            return;
1395        }
1396
1397        clear_openai_chatgpt_session().expect("clear session");
1398        assert!(
1399            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1400                .expect("load file session")
1401                .is_none()
1402        );
1403        assert!(
1404            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1405                .expect("load keyring session")
1406                .is_none()
1407        );
1408    }
1409
1410    #[test]
1411    fn active_api_bearer_token_falls_back_to_access_token() {
1412        let mut session = sample_session();
1413        session.openai_api_key.clear();
1414
1415        assert_eq!(active_api_bearer_token(&session), "oauth-access");
1416    }
1417
1418    #[test]
1419    fn parse_manual_callback_input_accepts_full_redirect_url() {
1420        let code = parse_openai_chatgpt_manual_callback_input(
1421            "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1422            "test-state",
1423        )
1424        .expect("manual input should parse");
1425        assert_eq!(code, "auth-code");
1426    }
1427
1428    #[test]
1429    fn parse_manual_callback_input_accepts_query_string() {
1430        let code = parse_openai_chatgpt_manual_callback_input(
1431            "code=auth-code&state=test-state",
1432            "test-state",
1433        )
1434        .expect("manual input should parse");
1435        assert_eq!(code, "auth-code");
1436    }
1437
1438    #[test]
1439    fn parse_manual_callback_input_rejects_bare_code() {
1440        let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1441            .expect_err("bare code should be rejected");
1442        assert!(
1443            error
1444                .to_string()
1445                .contains("full redirect url or query string")
1446        );
1447    }
1448
1449    #[test]
1450    fn parse_manual_callback_input_rejects_state_mismatch() {
1451        let error = parse_openai_chatgpt_manual_callback_input(
1452            "code=auth-code&state=wrong-state",
1453            "test-state",
1454        )
1455        .expect_err("state mismatch should fail");
1456        assert!(error.to_string().contains("state mismatch"));
1457    }
1458
1459    #[tokio::test]
1460    #[serial]
1461    async fn refresh_lock_serializes_parallel_acquisition() {
1462        let _guard = TestAuthDirGuard::new();
1463        let first = tokio::spawn(async {
1464            let _lock = acquire_refresh_lock().await.expect("first lock");
1465            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1466        });
1467        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1468
1469        let start = std::time::Instant::now();
1470        let second = tokio::spawn(async {
1471            let _lock = acquire_refresh_lock().await.expect("second lock");
1472        });
1473
1474        first.await.expect("first task");
1475        second.await.expect("second task");
1476        assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1477    }
1478}