Skip to main content

vtcode_auth/
openai_chatgpt_oauth.rs

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