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    clear_session_from_all_stores()
461}
462
463pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
464    match mode.effective_mode() {
465        AuthCredentialsStoreMode::Keyring => clear_session_from_keyring(),
466        AuthCredentialsStoreMode::File => clear_session_from_file(),
467        AuthCredentialsStoreMode::Auto => unreachable!(),
468    }
469}
470
471pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
472    get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
473}
474
475pub fn get_openai_chatgpt_auth_status_with_mode(
476    mode: AuthCredentialsStoreMode,
477) -> Result<OpenAIChatGptAuthStatus> {
478    let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
479        return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
480    };
481    let now = now_secs();
482    Ok(OpenAIChatGptAuthStatus::Authenticated {
483        label: session
484            .email
485            .clone()
486            .or_else(|| session.plan.clone())
487            .or_else(|| session.account_id.clone()),
488        age_seconds: now.saturating_sub(session.obtained_at),
489        expires_in: session
490            .expires_at
491            .map(|expires_at| expires_at.saturating_sub(now)),
492    })
493}
494
495pub async fn refresh_openai_chatgpt_session_from_refresh_token(
496    refresh_token: &str,
497    storage_mode: AuthCredentialsStoreMode,
498) -> Result<OpenAIChatGptSession> {
499    let _lock = acquire_refresh_lock().await?;
500    refresh_openai_chatgpt_session_without_lock(refresh_token, storage_mode).await
501}
502
503pub async fn refresh_openai_chatgpt_session_with_mode(
504    mode: AuthCredentialsStoreMode,
505) -> Result<OpenAIChatGptSession> {
506    let session = load_openai_chatgpt_session_with_mode(mode)?
507        .ok_or_else(|| anyhow!("Run vtcode login openai"))?;
508    refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
509}
510
511async fn refresh_openai_chatgpt_session_from_snapshot(
512    session: &OpenAIChatGptSession,
513    storage_mode: AuthCredentialsStoreMode,
514) -> Result<OpenAIChatGptSession> {
515    let _lock = acquire_refresh_lock().await?;
516    if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
517        && session_has_newer_refresh_state(&current, session)
518    {
519        return Ok(current);
520    }
521    refresh_openai_chatgpt_session_without_lock(&session.refresh_token, storage_mode).await
522}
523
524async fn refresh_openai_chatgpt_session_without_lock(
525    refresh_token: &str,
526    storage_mode: AuthCredentialsStoreMode,
527) -> Result<OpenAIChatGptSession> {
528    let response = Client::new()
529        .post(OPENAI_TOKEN_URL)
530        .header("Content-Type", "application/x-www-form-urlencoded")
531        .body(format!(
532            "grant_type=refresh_token&client_id={}&refresh_token={}",
533            urlencoding::encode(OPENAI_CLIENT_ID),
534            urlencoding::encode(refresh_token),
535        ))
536        .send()
537        .await
538        .context("failed to refresh openai chatgpt token")?;
539    response
540        .error_for_status_ref()
541        .map_err(classify_refresh_error)?;
542    let token_response: OpenAITokenResponse = response
543        .json()
544        .await
545        .context("failed to parse openai refresh response")?;
546
547    let session = build_session_from_token_response(token_response).await?;
548    save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
549    Ok(session)
550}
551
552async fn build_session_from_token_response(
553    token_response: OpenAITokenResponse,
554) -> Result<OpenAIChatGptSession> {
555    let id_claims = parse_jwt_claims(&token_response.id_token)?;
556    let access_claims = parse_jwt_claims(&token_response.access_token).ok();
557    let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
558        Ok(api_key) => api_key,
559        Err(err) => {
560            tracing::warn!(
561                "openai api-key exchange unavailable, falling back to oauth access token: {err}"
562            );
563            String::new()
564        }
565    };
566    let now = now_secs();
567    Ok(OpenAIChatGptSession {
568        openai_api_key: api_key,
569        id_token: token_response.id_token,
570        access_token: token_response.access_token,
571        refresh_token: token_response.refresh_token,
572        account_id: access_claims
573            .as_ref()
574            .and_then(|claims| claims.account_id.clone())
575            .or(id_claims.account_id),
576        email: id_claims.email.or_else(|| {
577            access_claims
578                .as_ref()
579                .and_then(|claims| claims.email.clone())
580        }),
581        plan: access_claims
582            .as_ref()
583            .and_then(|claims| claims.plan.clone())
584            .or(id_claims.plan),
585        obtained_at: now,
586        refreshed_at: now,
587        expires_at: token_response
588            .expires_in
589            .map(|secs| now.saturating_add(secs)),
590    })
591}
592
593async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
594    #[derive(Deserialize)]
595    struct ExchangeResponse {
596        access_token: String,
597    }
598
599    let exchange: ExchangeResponse = Client::new()
600        .post(OPENAI_TOKEN_URL)
601        .header("Content-Type", "application/x-www-form-urlencoded")
602        .body(format!(
603            "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
604            urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
605            urlencoding::encode(OPENAI_CLIENT_ID),
606            urlencoding::encode("openai-api-key"),
607            urlencoding::encode(id_token),
608            urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
609        ))
610        .send()
611        .await
612        .context("failed to exchange openai id token for api key")?
613        .error_for_status()
614        .context("openai api-key exchange failed")?
615        .json()
616        .await
617        .context("failed to parse openai api-key exchange response")?;
618
619    Ok(exchange.access_token)
620}
621
622#[derive(Debug, Deserialize)]
623struct OpenAITokenResponse {
624    id_token: String,
625    access_token: String,
626    refresh_token: String,
627    #[serde(default)]
628    expires_in: Option<u64>,
629}
630
631#[derive(Debug, Deserialize)]
632struct IdTokenClaims {
633    #[serde(default)]
634    email: Option<String>,
635    #[serde(rename = "https://api.openai.com/profile", default)]
636    profile: Option<ProfileClaims>,
637    #[serde(rename = "https://api.openai.com/auth", default)]
638    auth: Option<AuthClaims>,
639}
640
641#[derive(Debug, Deserialize)]
642struct ProfileClaims {
643    #[serde(default)]
644    email: Option<String>,
645}
646
647#[derive(Debug, Deserialize)]
648struct AuthClaims {
649    #[serde(default)]
650    chatgpt_plan_type: Option<String>,
651    #[serde(default)]
652    chatgpt_account_id: Option<String>,
653}
654
655#[derive(Debug)]
656struct ParsedIdTokenClaims {
657    email: Option<String>,
658    account_id: Option<String>,
659    plan: Option<String>,
660}
661
662fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
663    let mut parts = jwt.split('.');
664    let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
665        (Some(header), Some(payload), Some(signature))
666            if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
667        {
668            (header, payload, signature)
669        }
670        _ => bail!("invalid openai id token"),
671    };
672
673    let payload = URL_SAFE_NO_PAD
674        .decode(payload_b64)
675        .context("failed to decode openai id token payload")?;
676    let claims: IdTokenClaims =
677        serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
678
679    Ok(ParsedIdTokenClaims {
680        email: claims
681            .email
682            .or_else(|| claims.profile.and_then(|profile| profile.email)),
683        account_id: claims
684            .auth
685            .as_ref()
686            .and_then(|auth| auth.chatgpt_account_id.clone()),
687        plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
688    })
689}
690
691fn extract_query_value(query: &str, key: &str) -> Option<String> {
692    query
693        .trim_start_matches('?')
694        .split('&')
695        .filter_map(|pair| {
696            let (pair_key, pair_value) = pair.split_once('=')?;
697            (pair_key == key)
698                .then(|| {
699                    urlencoding::decode(pair_value)
700                        .ok()
701                        .map(|value| value.into_owned())
702                })
703                .flatten()
704        })
705        .find(|value| !value.is_empty())
706}
707
708fn session_has_newer_refresh_state(
709    current: &OpenAIChatGptSession,
710    previous: &OpenAIChatGptSession,
711) -> bool {
712    current.refresh_token != previous.refresh_token
713        || current.refreshed_at > previous.refreshed_at
714        || current.obtained_at > previous.obtained_at
715}
716
717struct RefreshLockGuard {
718    file: fs::File,
719}
720
721impl Drop for RefreshLockGuard {
722    fn drop(&mut self) {
723        let _ = FileExt::unlock(&self.file);
724    }
725}
726
727async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
728    let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
729    let file = OpenOptions::new()
730        .create(true)
731        .read(true)
732        .write(true)
733        .truncate(false)
734        .open(&path)
735        .with_context(|| format!("failed to open openai refresh lock {}", path.display()))?;
736    let file = tokio::task::spawn_blocking(move || {
737        file.lock_exclusive()
738            .context("failed to acquire openai refresh lock")?;
739        Ok::<_, anyhow::Error>(file)
740    })
741    .await
742    .context("openai refresh lock task failed")??;
743    Ok(RefreshLockGuard { file })
744}
745
746fn classify_refresh_error(err: reqwest::Error) -> anyhow::Error {
747    let status = err.status();
748    let message = err.to_string();
749    if status.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST)
750        && (message.contains("invalid_grant") || message.contains("refresh_token"))
751    {
752        if let Err(clear_err) = clear_session_from_all_stores() {
753            tracing::warn!(
754                "failed to clear expired openai chatgpt session across all stores: {clear_err}"
755            );
756        }
757        anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
758    } else {
759        anyhow!(message)
760    }
761}
762
763fn clear_session_from_all_stores() -> Result<()> {
764    let mut errors = Vec::new();
765
766    if let Err(err) = clear_session_from_keyring() {
767        errors.push(err.to_string());
768    }
769    if let Err(err) = clear_session_from_file() {
770        errors.push(err.to_string());
771    }
772
773    if errors.is_empty() {
774        Ok(())
775    } else {
776        Err(anyhow!(
777            "failed to clear openai session from all stores: {}",
778            errors.join("; ")
779        ))
780    }
781}
782
783fn save_session_to_keyring(serialized: &str) -> Result<()> {
784    let entry = keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
785        .context("failed to access keyring for openai session")?;
786    entry
787        .set_password(serialized)
788        .context("failed to store openai session in keyring")?;
789    Ok(())
790}
791
792fn persist_session_to_keyring_or_file(
793    session: &OpenAIChatGptSession,
794    serialized: &str,
795) -> Result<()> {
796    match save_session_to_keyring(serialized) {
797        Ok(()) => match load_session_from_keyring_decoded() {
798            Ok(Some(_)) => Ok(()),
799            Ok(None) => {
800                tracing::warn!(
801                    "openai session keyring write did not round-trip; falling back to encrypted file storage"
802                );
803                save_session_to_file(session)
804            }
805            Err(err) => {
806                tracing::warn!(
807                    "openai session keyring verification failed, falling back to encrypted file storage: {err}"
808                );
809                save_session_to_file(session)
810            }
811        },
812        Err(err) => {
813            tracing::warn!(
814                "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
815            );
816            save_session_to_file(session)
817                .context("failed to persist openai session after keyring fallback")
818        }
819    }
820}
821
822fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
823    serde_json::from_str(&serialized).context("failed to decode openai session")
824}
825
826fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
827    load_session_from_keyring()?
828        .map(decode_session_from_keyring)
829        .transpose()
830}
831
832fn load_preferred_openai_chatgpt_session(
833    mode: AuthCredentialsStoreMode,
834) -> Result<Option<OpenAIChatGptSession>> {
835    match mode {
836        AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
837            Ok(Some(session)) => Ok(Some(session)),
838            Ok(None) => load_session_from_file(),
839            Err(err) => {
840                tracing::warn!(
841                    "failed to load openai session from keyring, falling back to encrypted file: {err}"
842                );
843                load_session_from_file()
844            }
845        },
846        AuthCredentialsStoreMode::File => {
847            if let Some(session) = load_session_from_file()? {
848                return Ok(Some(session));
849            }
850            load_session_from_keyring_decoded()
851        }
852        AuthCredentialsStoreMode::Auto => unreachable!(),
853    }
854}
855
856fn load_session_from_keyring() -> Result<Option<String>> {
857    let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
858        Ok(entry) => entry,
859        Err(_) => return Ok(None),
860    };
861
862    match entry.get_password() {
863        Ok(value) => Ok(Some(value)),
864        Err(keyring::Error::NoEntry) => Ok(None),
865        Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
866    }
867}
868
869fn clear_session_from_keyring() -> Result<()> {
870    let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
871        Ok(entry) => entry,
872        Err(_) => return Ok(()),
873    };
874
875    match entry.delete_credential() {
876        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
877        Err(err) => Err(anyhow!(
878            "failed to clear openai session keyring entry: {err}"
879        )),
880    }
881}
882
883fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
884    let encrypted = encrypt_session(session)?;
885    let path = get_session_path()?;
886    fs::write(&path, serde_json::to_vec_pretty(&encrypted)?)
887        .context("failed to persist openai session file")?;
888    #[cfg(unix)]
889    {
890        use std::os::unix::fs::PermissionsExt;
891        fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
892            .context("failed to set openai session file permissions")?;
893    }
894    Ok(())
895}
896
897fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
898    let path = get_session_path()?;
899    let data = match fs::read(path) {
900        Ok(data) => data,
901        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
902        Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
903    };
904
905    let encrypted: EncryptedSession =
906        serde_json::from_slice(&data).context("failed to decode openai session file")?;
907    Ok(Some(decrypt_session(&encrypted)?))
908}
909
910fn clear_session_from_file() -> Result<()> {
911    let path = get_session_path()?;
912    match fs::remove_file(path) {
913        Ok(()) => Ok(()),
914        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
915        Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
916    }
917}
918
919fn get_session_path() -> Result<PathBuf> {
920    Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
921}
922
923#[derive(Debug, Serialize, Deserialize)]
924struct EncryptedSession {
925    nonce: String,
926    ciphertext: String,
927    version: u8,
928}
929
930fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
931    let key = derive_encryption_key()?;
932    let rng = SystemRandom::new();
933    let mut nonce_bytes = [0u8; NONCE_LEN];
934    rng.fill(&mut nonce_bytes)
935        .map_err(|_| anyhow!("failed to generate nonce"))?;
936
937    let mut ciphertext =
938        serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
939    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
940    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
941        .map_err(|_| anyhow!("failed to encrypt openai session"))?;
942
943    Ok(EncryptedSession {
944        nonce: STANDARD.encode(nonce_bytes),
945        ciphertext: STANDARD.encode(ciphertext),
946        version: 1,
947    })
948}
949
950fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
951    if encrypted.version != 1 {
952        bail!("unsupported openai session encryption format");
953    }
954
955    let nonce_bytes = STANDARD
956        .decode(&encrypted.nonce)
957        .context("failed to decode openai session nonce")?;
958    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
959        .try_into()
960        .map_err(|_| anyhow!("invalid openai session nonce length"))?;
961    let mut ciphertext = STANDARD
962        .decode(&encrypted.ciphertext)
963        .context("failed to decode openai session ciphertext")?;
964
965    let key = derive_encryption_key()?;
966    let plaintext = key
967        .open_in_place(
968            Nonce::assume_unique_for_key(nonce_array),
969            Aad::empty(),
970            &mut ciphertext,
971        )
972        .map_err(|_| anyhow!("failed to decrypt openai session"))?;
973    serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
974}
975
976fn derive_encryption_key() -> Result<LessSafeKey> {
977    use ring::digest::{SHA256, digest};
978
979    let mut key_material = Vec::new();
980    if let Ok(hostname) = hostname::get() {
981        key_material.extend_from_slice(hostname.as_encoded_bytes());
982    }
983
984    #[cfg(unix)]
985    {
986        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
987    }
988    #[cfg(not(unix))]
989    {
990        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
991            key_material.extend_from_slice(user.as_bytes());
992        }
993    }
994
995    key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
996    let hash = digest(&SHA256, &key_material);
997    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
998        .try_into()
999        .context("openai session encryption key was too short")?;
1000    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
1001        .map_err(|_| anyhow!("invalid openai session encryption key"))?;
1002    Ok(LessSafeKey::new(unbound))
1003}
1004
1005fn now_secs() -> u64 {
1006    std::time::SystemTime::now()
1007        .duration_since(std::time::UNIX_EPOCH)
1008        .map(|duration| duration.as_secs())
1009        .unwrap_or(0)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use assert_fs::TempDir;
1016    use serial_test::serial;
1017
1018    struct TestAuthDirGuard {
1019        temp_dir: Option<TempDir>,
1020        previous: Option<PathBuf>,
1021    }
1022
1023    impl TestAuthDirGuard {
1024        fn new() -> Self {
1025            let temp_dir = TempDir::new().expect("create temp auth dir");
1026            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1027                .expect("read auth dir override");
1028            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1029                temp_dir.path().to_path_buf(),
1030            ))
1031            .expect("set temp auth dir override");
1032            Self {
1033                temp_dir: Some(temp_dir),
1034                previous,
1035            }
1036        }
1037    }
1038
1039    impl Drop for TestAuthDirGuard {
1040        fn drop(&mut self) {
1041            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1042                .expect("restore auth dir override");
1043            if let Some(temp_dir) = self.temp_dir.take() {
1044                temp_dir.close().expect("remove temp auth dir");
1045            }
1046        }
1047    }
1048
1049    fn sample_session() -> OpenAIChatGptSession {
1050        OpenAIChatGptSession {
1051            openai_api_key: "api-key".to_string(),
1052            id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1053            access_token: "oauth-access".to_string(),
1054            refresh_token: "refresh-token".to_string(),
1055            account_id: Some("acc_123".to_string()),
1056            email: Some("test@example.com".to_string()),
1057            plan: Some("plus".to_string()),
1058            obtained_at: 10,
1059            refreshed_at: 10,
1060            expires_at: Some(now_secs() + 3600),
1061        }
1062    }
1063
1064    #[test]
1065    fn auth_url_contains_expected_openai_parameters() {
1066        let challenge = PkceChallenge {
1067            code_verifier: "verifier".to_string(),
1068            code_challenge: "challenge".to_string(),
1069            code_challenge_method: "S256".to_string(),
1070        };
1071
1072        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1073        assert!(url.starts_with(OPENAI_AUTH_URL));
1074        assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1075        assert!(url.contains("code_challenge=challenge"));
1076        assert!(url.contains("codex_cli_simplified_flow=true"));
1077        assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1078        assert!(url.contains("state=test-state"));
1079    }
1080
1081    #[test]
1082    fn parse_jwt_claims_extracts_openai_claims() {
1083        let claims = parse_jwt_claims(
1084            "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1085        )
1086        .expect("claims");
1087        assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1088        assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1089        assert_eq!(claims.plan.as_deref(), Some("plus"));
1090    }
1091
1092    #[test]
1093    fn session_refresh_due_uses_expiry_and_age() {
1094        let mut session = sample_session();
1095        let now = now_secs();
1096        session.obtained_at = now;
1097        session.refreshed_at = now;
1098        session.expires_at = Some(now + 3600);
1099        assert!(!session.is_refresh_due());
1100        session.expires_at = Some(now);
1101        assert!(session.is_refresh_due());
1102    }
1103
1104    #[test]
1105    #[serial]
1106    fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1107        let _guard = TestAuthDirGuard::new();
1108        let session = sample_session();
1109        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1110            .expect("save session");
1111        let resolved = resolve_openai_auth(
1112            &OpenAIAuthConfig::default(),
1113            AuthCredentialsStoreMode::File,
1114            Some("api-key".to_string()),
1115        )
1116        .expect("resolved auth");
1117        assert!(resolved.using_chatgpt());
1118        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1119            .expect("clear session");
1120    }
1121
1122    #[test]
1123    #[serial]
1124    fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1125        let _guard = TestAuthDirGuard::new();
1126        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1127            .expect("clear session");
1128        let resolved = resolve_openai_auth(
1129            &OpenAIAuthConfig::default(),
1130            AuthCredentialsStoreMode::File,
1131            Some("api-key".to_string()),
1132        )
1133        .expect("resolved auth");
1134        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1135    }
1136
1137    #[test]
1138    #[serial]
1139    fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1140        let _guard = TestAuthDirGuard::new();
1141        let session = sample_session();
1142        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1143            .expect("save session");
1144        let resolved = resolve_openai_auth(
1145            &OpenAIAuthConfig {
1146                preferred_method: OpenAIPreferredMethod::ApiKey,
1147                ..OpenAIAuthConfig::default()
1148            },
1149            AuthCredentialsStoreMode::File,
1150            Some("api-key".to_string()),
1151        )
1152        .expect("resolved auth");
1153        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1154        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1155            .expect("clear session");
1156    }
1157
1158    #[test]
1159    #[serial]
1160    fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1161        let _guard = TestAuthDirGuard::new();
1162        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1163            .expect("clear session");
1164        let error = resolve_openai_auth(
1165            &OpenAIAuthConfig {
1166                preferred_method: OpenAIPreferredMethod::Chatgpt,
1167                ..OpenAIAuthConfig::default()
1168            },
1169            AuthCredentialsStoreMode::File,
1170            Some("api-key".to_string()),
1171        )
1172        .expect_err("chatgpt mode should require a stored session");
1173        assert!(error.to_string().contains("vtcode login openai"));
1174    }
1175
1176    #[test]
1177    #[serial]
1178    fn summarize_openai_credentials_reports_dual_source_notice() {
1179        let _guard = TestAuthDirGuard::new();
1180        let session = sample_session();
1181        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1182            .expect("save session");
1183        let overview = summarize_openai_credentials(
1184            &OpenAIAuthConfig::default(),
1185            AuthCredentialsStoreMode::File,
1186            Some("api-key".to_string()),
1187        )
1188        .expect("overview");
1189        assert_eq!(
1190            overview.active_source,
1191            Some(OpenAIResolvedAuthSource::ChatGpt)
1192        );
1193        assert!(overview.notice.is_some());
1194        assert!(overview.recommendation.is_some());
1195        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1196            .expect("clear session");
1197    }
1198
1199    #[test]
1200    #[serial]
1201    fn summarize_openai_credentials_respects_api_key_preference() {
1202        let _guard = TestAuthDirGuard::new();
1203        let session = sample_session();
1204        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1205            .expect("save session");
1206        let overview = summarize_openai_credentials(
1207            &OpenAIAuthConfig {
1208                preferred_method: OpenAIPreferredMethod::ApiKey,
1209                ..OpenAIAuthConfig::default()
1210            },
1211            AuthCredentialsStoreMode::File,
1212            Some("api-key".to_string()),
1213        )
1214        .expect("overview");
1215        assert_eq!(
1216            overview.active_source,
1217            Some(OpenAIResolvedAuthSource::ApiKey)
1218        );
1219        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1220            .expect("clear session");
1221    }
1222
1223    #[test]
1224    fn encrypted_file_round_trip_restores_session() {
1225        let session = sample_session();
1226        let encrypted = encrypt_session(&session).expect("encrypt");
1227        let decrypted = decrypt_session(&encrypted).expect("decrypt");
1228        assert_eq!(decrypted.account_id, session.account_id);
1229        assert_eq!(decrypted.email, session.email);
1230        assert_eq!(decrypted.plan, session.plan);
1231    }
1232
1233    #[test]
1234    #[serial]
1235    fn default_loader_falls_back_to_file_session() {
1236        let _guard = TestAuthDirGuard::new();
1237        let session = sample_session();
1238        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1239            .expect("save session");
1240
1241        let loaded = load_openai_chatgpt_session()
1242            .expect("load session")
1243            .expect("stored session should be found");
1244
1245        assert_eq!(loaded.account_id, session.account_id);
1246        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1247            .expect("clear session");
1248    }
1249
1250    #[test]
1251    #[serial]
1252    fn keyring_mode_loader_falls_back_to_file_session() {
1253        let _guard = TestAuthDirGuard::new();
1254        let session = sample_session();
1255        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1256            .expect("save session");
1257
1258        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1259            .expect("load session")
1260            .expect("stored session should be found");
1261
1262        assert_eq!(loaded.email, session.email);
1263        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1264            .expect("clear session");
1265    }
1266
1267    #[test]
1268    #[serial]
1269    fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1270        let _guard = TestAuthDirGuard::new();
1271        let session = sample_session();
1272        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1273            .expect("save file session");
1274
1275        if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring)
1276            .is_err()
1277        {
1278            clear_openai_chatgpt_session().expect("clear session");
1279            assert!(
1280                load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1281                    .expect("load file session")
1282                    .is_none()
1283            );
1284            return;
1285        }
1286
1287        clear_openai_chatgpt_session().expect("clear session");
1288        assert!(
1289            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1290                .expect("load file session")
1291                .is_none()
1292        );
1293        assert!(
1294            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1295                .expect("load keyring session")
1296                .is_none()
1297        );
1298    }
1299
1300    #[test]
1301    fn active_api_bearer_token_falls_back_to_access_token() {
1302        let mut session = sample_session();
1303        session.openai_api_key.clear();
1304
1305        assert_eq!(active_api_bearer_token(&session), "oauth-access");
1306    }
1307
1308    #[test]
1309    fn parse_manual_callback_input_accepts_full_redirect_url() {
1310        let code = parse_openai_chatgpt_manual_callback_input(
1311            "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1312            "test-state",
1313        )
1314        .expect("manual input should parse");
1315        assert_eq!(code, "auth-code");
1316    }
1317
1318    #[test]
1319    fn parse_manual_callback_input_accepts_query_string() {
1320        let code = parse_openai_chatgpt_manual_callback_input(
1321            "code=auth-code&state=test-state",
1322            "test-state",
1323        )
1324        .expect("manual input should parse");
1325        assert_eq!(code, "auth-code");
1326    }
1327
1328    #[test]
1329    fn parse_manual_callback_input_rejects_bare_code() {
1330        let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1331            .expect_err("bare code should be rejected");
1332        assert!(
1333            error
1334                .to_string()
1335                .contains("full redirect url or query string")
1336        );
1337    }
1338
1339    #[test]
1340    fn parse_manual_callback_input_rejects_state_mismatch() {
1341        let error = parse_openai_chatgpt_manual_callback_input(
1342            "code=auth-code&state=wrong-state",
1343            "test-state",
1344        )
1345        .expect_err("state mismatch should fail");
1346        assert!(error.to_string().contains("state mismatch"));
1347    }
1348
1349    #[tokio::test]
1350    #[serial]
1351    async fn refresh_lock_serializes_parallel_acquisition() {
1352        let _guard = TestAuthDirGuard::new();
1353        let first = tokio::spawn(async {
1354            let _lock = acquire_refresh_lock().await.expect("first lock");
1355            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1356        });
1357        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1358
1359        let start = std::time::Instant::now();
1360        let second = tokio::spawn(async {
1361            let _lock = acquire_refresh_lock().await.expect("second lock");
1362        });
1363
1364        first.await.expect("first task");
1365        second.await.expect("second task");
1366        assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1367    }
1368}