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