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
729#[cold]
730fn classify_refresh_error(err: reqwest::Error) -> anyhow::Error {
731    let status = err.status();
732    let message = err.to_string();
733    if status.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST)
734        && (message.contains("invalid_grant") || message.contains("refresh_token"))
735    {
736        if let Err(clear_err) = clear_session_from_all_stores() {
737            tracing::warn!(
738                "failed to clear expired openai chatgpt session across all stores: {clear_err}"
739            );
740        }
741        anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
742    } else {
743        anyhow!(message)
744    }
745}
746
747fn clear_session_from_all_stores() -> Result<()> {
748    let mut errors = Vec::new();
749
750    if let Err(err) = clear_session_from_keyring() {
751        errors.push(err.to_string());
752    }
753    if let Err(err) = clear_session_from_file() {
754        errors.push(err.to_string());
755    }
756
757    if errors.is_empty() {
758        Ok(())
759    } else {
760        Err(anyhow!(
761            "failed to clear openai session from all stores: {}",
762            errors.join("; ")
763        ))
764    }
765}
766
767fn save_session_to_keyring(serialized: &str) -> Result<()> {
768    let entry = keyring_entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
769        .context("failed to access keyring for openai session")?;
770    entry
771        .set_password(serialized)
772        .context("failed to store openai session in keyring")?;
773    Ok(())
774}
775
776fn persist_session_to_keyring_or_file(
777    session: &OpenAIChatGptSession,
778    serialized: &str,
779) -> Result<()> {
780    match save_session_to_keyring(serialized) {
781        Ok(()) => match load_session_from_keyring_decoded() {
782            Ok(Some(_)) => Ok(()),
783            Ok(None) => {
784                tracing::warn!(
785                    "openai session keyring write did not round-trip; falling back to encrypted file storage"
786                );
787                save_session_to_file(session)
788            }
789            Err(err) => {
790                tracing::warn!(
791                    "openai session keyring verification failed, falling back to encrypted file storage: {err}"
792                );
793                save_session_to_file(session)
794            }
795        },
796        Err(err) => {
797            tracing::warn!(
798                "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
799            );
800            save_session_to_file(session)
801                .context("failed to persist openai session after keyring fallback")
802        }
803    }
804}
805
806fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
807    serde_json::from_str(&serialized).context("failed to decode openai session")
808}
809
810fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
811    load_session_from_keyring()?
812        .map(decode_session_from_keyring)
813        .transpose()
814}
815
816fn load_preferred_openai_chatgpt_session(
817    mode: AuthCredentialsStoreMode,
818) -> Result<Option<OpenAIChatGptSession>> {
819    match mode {
820        AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
821            Ok(Some(session)) => Ok(Some(session)),
822            Ok(None) => load_session_from_file(),
823            Err(err) => {
824                tracing::warn!(
825                    "failed to load openai session from keyring, falling back to encrypted file: {err}"
826                );
827                load_session_from_file()
828            }
829        },
830        AuthCredentialsStoreMode::File => {
831            if let Some(session) = load_session_from_file()? {
832                return Ok(Some(session));
833            }
834            load_session_from_keyring_decoded()
835        }
836        AuthCredentialsStoreMode::Auto => unreachable!(),
837    }
838}
839
840fn load_session_from_keyring() -> Result<Option<String>> {
841    let entry = match keyring_entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
842        Ok(entry) => entry,
843        Err(_) => return Ok(None),
844    };
845
846    match entry.get_password() {
847        Ok(value) => Ok(Some(value)),
848        Err(keyring_core::Error::NoEntry) => Ok(None),
849        Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
850    }
851}
852
853fn clear_session_from_keyring() -> Result<()> {
854    let entry = match keyring_entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
855        Ok(entry) => entry,
856        Err(_) => return Ok(()),
857    };
858
859    match entry.delete_credential() {
860        Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
861        Err(err) => Err(anyhow!(
862            "failed to clear openai session keyring entry: {err}"
863        )),
864    }
865}
866
867fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
868    let encrypted = encrypt_session(session)?;
869    let path = get_session_path()?;
870    let payload = serde_json::to_vec_pretty(&encrypted)?;
871    write_private_file(&path, &payload).context("failed to persist openai session file")?;
872    Ok(())
873}
874
875fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
876    let path = get_session_path()?;
877    let data = match fs::read(path) {
878        Ok(data) => data,
879        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
880        Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
881    };
882
883    let encrypted: EncryptedSession =
884        serde_json::from_slice(&data).context("failed to decode openai session file")?;
885    Ok(Some(decrypt_session(&encrypted)?))
886}
887
888fn clear_session_from_file() -> Result<()> {
889    let path = get_session_path()?;
890    match fs::remove_file(path) {
891        Ok(()) => Ok(()),
892        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
893        Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
894    }
895}
896
897fn get_session_path() -> Result<PathBuf> {
898    Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
899}
900
901#[derive(Debug, Serialize, Deserialize)]
902struct EncryptedSession {
903    nonce: String,
904    ciphertext: String,
905    version: u8,
906}
907
908fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
909    let key = derive_encryption_key()?;
910    let rng = SystemRandom::new();
911    let mut nonce_bytes = [0u8; NONCE_LEN];
912    rng.fill(&mut nonce_bytes)
913        .map_err(|_| anyhow!("failed to generate nonce"))?;
914
915    let mut ciphertext =
916        serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
917    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
918    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
919        .map_err(|_| anyhow!("failed to encrypt openai session"))?;
920
921    Ok(EncryptedSession {
922        nonce: STANDARD.encode(nonce_bytes),
923        ciphertext: STANDARD.encode(ciphertext),
924        version: 1,
925    })
926}
927
928fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
929    if encrypted.version != 1 {
930        bail!("unsupported openai session encryption format");
931    }
932
933    let nonce_bytes = STANDARD
934        .decode(&encrypted.nonce)
935        .context("failed to decode openai session nonce")?;
936    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
937        .try_into()
938        .map_err(|_| anyhow!("invalid openai session nonce length"))?;
939    let mut ciphertext = STANDARD
940        .decode(&encrypted.ciphertext)
941        .context("failed to decode openai session ciphertext")?;
942
943    let key = derive_encryption_key()?;
944    let plaintext = key
945        .open_in_place(
946            Nonce::assume_unique_for_key(nonce_array),
947            Aad::empty(),
948            &mut ciphertext,
949        )
950        .map_err(|_| anyhow!("failed to decrypt openai session"))?;
951    serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
952}
953
954fn derive_encryption_key() -> Result<LessSafeKey> {
955    use ring::digest::{SHA256, digest};
956
957    let mut key_material = Vec::new();
958    if let Ok(hostname) = hostname::get() {
959        key_material.extend_from_slice(hostname.as_encoded_bytes());
960    }
961
962    #[cfg(unix)]
963    {
964        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
965    }
966    #[cfg(not(unix))]
967    {
968        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
969            key_material.extend_from_slice(user.as_bytes());
970        }
971    }
972
973    key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
974    let hash = digest(&SHA256, &key_material);
975    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
976        .try_into()
977        .context("openai session encryption key was too short")?;
978    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
979        .map_err(|_| anyhow!("invalid openai session encryption key"))?;
980    Ok(LessSafeKey::new(unbound))
981}
982
983fn now_secs() -> u64 {
984    std::time::SystemTime::now()
985        .duration_since(std::time::UNIX_EPOCH)
986        .map(|duration| duration.as_secs())
987        .unwrap_or(0)
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use assert_fs::TempDir;
994    use serial_test::serial;
995    use std::sync::Arc;
996
997    struct ExternalRefresher;
998
999    #[async_trait]
1000    impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1001        async fn refresh_session(
1002            &self,
1003            current: &OpenAIChatGptSession,
1004        ) -> Result<OpenAIChatGptSession> {
1005            let mut refreshed = current.clone();
1006            refreshed.access_token = "oauth-access-refreshed".to_string();
1007            refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1008            refreshed.expires_at = Some(now_secs() + 3600);
1009            Ok(refreshed)
1010        }
1011    }
1012
1013    struct TestAuthDirGuard {
1014        temp_dir: Option<TempDir>,
1015        previous: Option<PathBuf>,
1016    }
1017
1018    impl TestAuthDirGuard {
1019        fn new() -> Self {
1020            let temp_dir = TempDir::new().expect("create temp auth dir");
1021            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1022                .expect("read auth dir override");
1023            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1024                temp_dir.path().to_path_buf(),
1025            ))
1026            .expect("set temp auth dir override");
1027            Self {
1028                temp_dir: Some(temp_dir),
1029                previous,
1030            }
1031        }
1032    }
1033
1034    impl Drop for TestAuthDirGuard {
1035        fn drop(&mut self) {
1036            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1037                .expect("restore auth dir override");
1038            if let Some(temp_dir) = self.temp_dir.take() {
1039                temp_dir.close().expect("remove temp auth dir");
1040            }
1041        }
1042    }
1043
1044    fn sample_session() -> OpenAIChatGptSession {
1045        OpenAIChatGptSession {
1046            openai_api_key: "api-key".to_string(),
1047            id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1048            access_token: "oauth-access".to_string(),
1049            refresh_token: "refresh-token".to_string(),
1050            account_id: Some("acc_123".to_string()),
1051            email: Some("test@example.com".to_string()),
1052            plan: Some("plus".to_string()),
1053            obtained_at: 10,
1054            refreshed_at: 10,
1055            expires_at: Some(now_secs() + 3600),
1056        }
1057    }
1058
1059    #[test]
1060    fn auth_url_contains_expected_openai_parameters() {
1061        let challenge = PkceChallenge {
1062            code_verifier: "verifier".to_string(),
1063            code_challenge: "challenge".to_string(),
1064            code_challenge_method: "S256".to_string(),
1065        };
1066
1067        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1068        assert!(url.starts_with(OPENAI_AUTH_URL));
1069        assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1070        assert!(url.contains("code_challenge=challenge"));
1071        assert!(url.contains("codex_cli_simplified_flow=true"));
1072        assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1073        assert!(url.contains("state=test-state"));
1074    }
1075
1076    #[test]
1077    fn parse_jwt_claims_extracts_openai_claims() {
1078        let claims = parse_jwt_claims(
1079            "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1080        )
1081        .expect("claims");
1082        assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1083        assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1084        assert_eq!(claims.plan.as_deref(), Some("plus"));
1085    }
1086
1087    #[test]
1088    fn session_refresh_due_uses_expiry_and_age() {
1089        let mut session = sample_session();
1090        let now = now_secs();
1091        session.obtained_at = now;
1092        session.refreshed_at = now;
1093        session.expires_at = Some(now + 3600);
1094        assert!(!session.is_refresh_due());
1095        session.expires_at = Some(now);
1096        assert!(session.is_refresh_due());
1097    }
1098
1099    #[tokio::test]
1100    #[serial]
1101    async fn external_auth_handle_refreshes_without_persisting_session() {
1102        let _guard = TestAuthDirGuard::new();
1103        let mut session = sample_session();
1104        session.openai_api_key.clear();
1105        session.expires_at = Some(now_secs().saturating_sub(1));
1106        let handle =
1107            OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1108
1109        assert!(handle.using_external_tokens());
1110        assert!(
1111            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1112                .expect("load session")
1113                .is_none()
1114        );
1115
1116        handle.force_refresh().await.expect("force refresh");
1117
1118        assert_eq!(
1119            handle.current_api_key().expect("current api key"),
1120            "oauth-access-refreshed"
1121        );
1122        assert!(
1123            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1124                .expect("load session")
1125                .is_none()
1126        );
1127    }
1128
1129    struct CountingExternalRefresher {
1130        calls: Arc<Mutex<usize>>,
1131    }
1132
1133    #[async_trait]
1134    impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1135        async fn refresh_session(
1136            &self,
1137            current: &OpenAIChatGptSession,
1138        ) -> Result<OpenAIChatGptSession> {
1139            let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1140            *calls += 1;
1141            drop(calls);
1142
1143            let mut refreshed = current.clone();
1144            refreshed.access_token = "oauth-access-refreshed".to_string();
1145            refreshed.refreshed_at = now_secs();
1146            refreshed.expires_at = Some(now_secs() + 3600);
1147            Ok(refreshed)
1148        }
1149    }
1150
1151    #[tokio::test]
1152    async fn refresh_if_needed_serializes_external_refreshes() {
1153        let mut session = sample_session();
1154        session.openai_api_key.clear();
1155        session.expires_at = Some(now_secs().saturating_sub(1));
1156        let calls = Arc::new(Mutex::new(0usize));
1157        let handle = OpenAIChatGptAuthHandle::new_external(
1158            session,
1159            true,
1160            Arc::new(CountingExternalRefresher {
1161                calls: Arc::clone(&calls),
1162            }),
1163        );
1164
1165        let first = handle.clone();
1166        let second = handle.clone();
1167        let (first_result, second_result) =
1168            tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1169
1170        first_result.expect("first refresh should succeed");
1171        second_result.expect("second refresh should succeed");
1172        assert_eq!(
1173            *calls.lock().expect("refresh calls mutex should lock"),
1174            1,
1175            "concurrent refresh_if_needed calls should share one refresh"
1176        );
1177        assert_eq!(
1178            handle.current_api_key().expect("current api key"),
1179            "oauth-access-refreshed"
1180        );
1181    }
1182
1183    #[test]
1184    #[serial]
1185    fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1186        let _guard = TestAuthDirGuard::new();
1187        let session = sample_session();
1188        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1189            .expect("save session");
1190        let resolved = resolve_openai_auth(
1191            &OpenAIAuthConfig::default(),
1192            AuthCredentialsStoreMode::File,
1193            Some("api-key".to_string()),
1194        )
1195        .expect("resolved auth");
1196        assert!(resolved.using_chatgpt());
1197        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1198            .expect("clear session");
1199    }
1200
1201    #[test]
1202    #[serial]
1203    #[cfg(unix)]
1204    fn file_storage_uses_private_permissions() {
1205        use std::fs;
1206        use std::os::unix::fs::PermissionsExt;
1207
1208        let _guard = TestAuthDirGuard::new();
1209        let session = sample_session();
1210
1211        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1212            .expect("save session");
1213
1214        let metadata =
1215            fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1216        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1217    }
1218
1219    #[test]
1220    #[serial]
1221    fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1222        let _guard = TestAuthDirGuard::new();
1223        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1224            .expect("clear session");
1225        let resolved = resolve_openai_auth(
1226            &OpenAIAuthConfig::default(),
1227            AuthCredentialsStoreMode::File,
1228            Some("api-key".to_string()),
1229        )
1230        .expect("resolved auth");
1231        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1232    }
1233
1234    #[test]
1235    #[serial]
1236    fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1237        let _guard = TestAuthDirGuard::new();
1238        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1239            .expect("clear session");
1240        let error = resolve_openai_auth(
1241            &OpenAIAuthConfig::default(),
1242            AuthCredentialsStoreMode::File,
1243            Some("   ".to_string()),
1244        )
1245        .expect_err("blank api key should fail");
1246        assert!(error.to_string().contains("OpenAI API key not found"));
1247    }
1248
1249    #[test]
1250    #[serial]
1251    fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1252        let _guard = TestAuthDirGuard::new();
1253        let session = sample_session();
1254        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1255            .expect("save session");
1256        let resolved = resolve_openai_auth(
1257            &OpenAIAuthConfig {
1258                preferred_method: OpenAIPreferredMethod::ApiKey,
1259                ..OpenAIAuthConfig::default()
1260            },
1261            AuthCredentialsStoreMode::File,
1262            Some("api-key".to_string()),
1263        )
1264        .expect("resolved auth");
1265        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1266        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1267            .expect("clear session");
1268    }
1269
1270    #[test]
1271    #[serial]
1272    fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1273        let _guard = TestAuthDirGuard::new();
1274        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1275            .expect("clear session");
1276        let error = resolve_openai_auth(
1277            &OpenAIAuthConfig {
1278                preferred_method: OpenAIPreferredMethod::Chatgpt,
1279                ..OpenAIAuthConfig::default()
1280            },
1281            AuthCredentialsStoreMode::File,
1282            Some("api-key".to_string()),
1283        )
1284        .expect_err("chatgpt mode should require a stored session");
1285        assert!(error.to_string().contains("vtcode login openai"));
1286    }
1287
1288    #[test]
1289    #[serial]
1290    fn summarize_openai_credentials_reports_dual_source_notice() {
1291        let _guard = TestAuthDirGuard::new();
1292        let session = sample_session();
1293        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1294            .expect("save session");
1295        let overview = summarize_openai_credentials(
1296            &OpenAIAuthConfig::default(),
1297            AuthCredentialsStoreMode::File,
1298            Some("api-key".to_string()),
1299        )
1300        .expect("overview");
1301        assert_eq!(
1302            overview.active_source,
1303            Some(OpenAIResolvedAuthSource::ChatGpt)
1304        );
1305        assert!(overview.notice.is_some());
1306        assert!(overview.recommendation.is_some());
1307        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1308            .expect("clear session");
1309    }
1310
1311    #[test]
1312    #[serial]
1313    fn summarize_openai_credentials_respects_api_key_preference() {
1314        let _guard = TestAuthDirGuard::new();
1315        let session = sample_session();
1316        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1317            .expect("save session");
1318        let overview = summarize_openai_credentials(
1319            &OpenAIAuthConfig {
1320                preferred_method: OpenAIPreferredMethod::ApiKey,
1321                ..OpenAIAuthConfig::default()
1322            },
1323            AuthCredentialsStoreMode::File,
1324            Some("api-key".to_string()),
1325        )
1326        .expect("overview");
1327        assert_eq!(
1328            overview.active_source,
1329            Some(OpenAIResolvedAuthSource::ApiKey)
1330        );
1331        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1332            .expect("clear session");
1333    }
1334
1335    #[test]
1336    fn encrypted_file_round_trip_restores_session() {
1337        let session = sample_session();
1338        let encrypted = encrypt_session(&session).expect("encrypt");
1339        let decrypted = decrypt_session(&encrypted).expect("decrypt");
1340        assert_eq!(decrypted.account_id, session.account_id);
1341        assert_eq!(decrypted.email, session.email);
1342        assert_eq!(decrypted.plan, session.plan);
1343    }
1344
1345    #[test]
1346    #[serial]
1347    fn default_loader_falls_back_to_file_session() {
1348        let _guard = TestAuthDirGuard::new();
1349        let session = sample_session();
1350        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1351            .expect("save session");
1352
1353        let loaded = load_openai_chatgpt_session()
1354            .expect("load session")
1355            .expect("stored session should be found");
1356
1357        assert_eq!(loaded.account_id, session.account_id);
1358        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1359            .expect("clear session");
1360    }
1361
1362    #[test]
1363    #[serial]
1364    fn keyring_mode_loader_falls_back_to_file_session() {
1365        let _guard = TestAuthDirGuard::new();
1366        let session = sample_session();
1367        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1368            .expect("save session");
1369
1370        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1371            .expect("load session")
1372            .expect("stored session should be found");
1373
1374        assert_eq!(loaded.email, session.email);
1375        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1376            .expect("clear session");
1377    }
1378
1379    #[test]
1380    #[serial]
1381    fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1382        let _guard = TestAuthDirGuard::new();
1383        let session = sample_session();
1384        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1385            .expect("save file session");
1386
1387        if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring)
1388            .is_err()
1389        {
1390            clear_openai_chatgpt_session().expect("clear session");
1391            assert!(
1392                load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1393                    .expect("load file session")
1394                    .is_none()
1395            );
1396            return;
1397        }
1398
1399        clear_openai_chatgpt_session().expect("clear session");
1400        assert!(
1401            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1402                .expect("load file session")
1403                .is_none()
1404        );
1405        assert!(
1406            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1407                .expect("load keyring session")
1408                .is_none()
1409        );
1410    }
1411
1412    #[test]
1413    fn active_api_bearer_token_falls_back_to_access_token() {
1414        let mut session = sample_session();
1415        session.openai_api_key.clear();
1416
1417        assert_eq!(active_api_bearer_token(&session), "oauth-access");
1418    }
1419
1420    #[test]
1421    fn parse_manual_callback_input_accepts_full_redirect_url() {
1422        let code = parse_openai_chatgpt_manual_callback_input(
1423            "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1424            "test-state",
1425        )
1426        .expect("manual input should parse");
1427        assert_eq!(code, "auth-code");
1428    }
1429
1430    #[test]
1431    fn parse_manual_callback_input_accepts_query_string() {
1432        let code = parse_openai_chatgpt_manual_callback_input(
1433            "code=auth-code&state=test-state",
1434            "test-state",
1435        )
1436        .expect("manual input should parse");
1437        assert_eq!(code, "auth-code");
1438    }
1439
1440    #[test]
1441    fn parse_manual_callback_input_rejects_bare_code() {
1442        let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1443            .expect_err("bare code should be rejected");
1444        assert!(
1445            error
1446                .to_string()
1447                .contains("full redirect url or query string")
1448        );
1449    }
1450
1451    #[test]
1452    fn parse_manual_callback_input_rejects_state_mismatch() {
1453        let error = parse_openai_chatgpt_manual_callback_input(
1454            "code=auth-code&state=wrong-state",
1455            "test-state",
1456        )
1457        .expect_err("state mismatch should fail");
1458        assert!(error.to_string().contains("state mismatch"));
1459    }
1460
1461    #[tokio::test]
1462    #[serial]
1463    async fn refresh_lock_serializes_parallel_acquisition() {
1464        let _guard = TestAuthDirGuard::new();
1465        let first = tokio::spawn(async {
1466            let _lock = acquire_refresh_lock().await.expect("first lock");
1467            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1468        });
1469        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1470
1471        let start = std::time::Instant::now();
1472        let second = tokio::spawn(async {
1473            let _lock = acquire_refresh_lock().await.expect("second lock");
1474        });
1475
1476        first.await.expect("first task");
1477        second.await.expect("second task");
1478        assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1479    }
1480}