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