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