Skip to main content

vtcode_auth/
openai_chatgpt_oauth.rs

1//! OpenAI ChatGPT subscription OAuth flow and secure session storage.
2//!
3//! This module implements an OAuth 2.0 PKCE authorization-code flow for ChatGPT
4//! subscription auth, mirroring the flow used by [openai/codex]. By default VT
5//! Code reuses the Codex CLI's **public PKCE OAuth client identity** (no client
6//! secret — the ID is not a secret by OAuth 2.1 design). This is an **unofficial
7//! compatibility mechanism**: OpenAI has not documented or guaranteed third-party
8//! reuse of this client identity, and a public client ID is not authorization
9//! to reuse another tool's OAuth registration. This allows ChatGPT subscription
10//! login to work without the Codex CLI installed.
11//! Organizations with their own OpenAI-issued client can override via
12//! `VTCODE_OPENAI_OAUTH_CLIENT_ID` / `VTCODE_OPENAI_OAUTH_ORIGINATOR`.
13//!
14//! - OAuth authorization-code flow with PKCE
15//! - refresh-token exchange
16//! - token exchange for an OpenAI API-key-style bearer token
17//! - secure storage in keyring or encrypted file storage
18//!
19//! Based on patterns from [openai/codex] (Apache-2.0). Copyright 2025 OpenAI.
20//! See the repository `THIRD-PARTY-NOTICES` file for full attribution.
21//!
22//! [openai/codex]: https://github.com/openai/codex
23
24use anyhow::{Context, Result, anyhow, bail};
25use async_trait::async_trait;
26use base64::{Engine, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD};
27use fs2::FileExt;
28use reqwest::Client;
29use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
30use ring::rand::{SecureRandom, SystemRandom};
31use serde::{Deserialize, Serialize};
32use std::fmt;
33use std::fs;
34use std::fs::OpenOptions;
35use std::path::PathBuf;
36use std::sync::{Arc, Mutex};
37use tokio::sync::Mutex as AsyncMutex;
38
39use crate::storage_paths::{auth_storage_dir, write_private_file};
40use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
41
42pub use super::credentials::AuthCredentialsStoreMode;
43use super::credentials::keyring;
44use super::pkce::PkceChallenge;
45
46const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
47const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
48/// Default OAuth client identity.
49///
50/// This is the **Codex CLI's public PKCE OAuth client ID**. VT Code reuses
51/// Codex's public client identity (a PKCE public client with no client secret
52/// — the ID is not a secret by OAuth 2.1 design) as an **unofficial
53/// compatibility mechanism**. OpenAI has not documented or guaranteed
54/// third-party reuse of this identity, and a public client ID is not
55/// authorization to reuse another tool's OAuth registration. This lets VT
56/// Code perform ChatGPT subscription login without requiring the Codex CLI
57/// to be installed.
58///
59/// Organizations with their own OpenAI-issued OAuth client can override this
60/// via the `VTCODE_OPENAI_OAUTH_CLIENT_ID` environment variable.
61///
62/// See `docs/guides/oauth-authentication.md` for the full explanation.
63const DEFAULT_OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
64/// Default originator sent to OpenAI's authorization endpoint.
65///
66/// This matches the Codex CLI's originator because the default client ID is
67/// Codex's. Override with `VTCODE_OPENAI_OAUTH_ORIGINATOR` when using a custom
68/// client ID.
69const DEFAULT_OPENAI_ORIGINATOR: &str = "codex_cli_rs";
70/// Maximum bytes read from a token-endpoint error response body for
71/// classification. Prevents unbounded reads from a misbehaving or hostile
72/// endpoint while still capturing standard OAuth 2.0 error JSON.
73const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
74const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
75const OPENAI_STORAGE_SERVICE: &str = "vtcode";
76const OPENAI_STORAGE_USER: &str = "openai_chatgpt_session";
77const OPENAI_SESSION_FILE: &str = "openai_chatgpt.json";
78const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
79const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
80const REFRESH_SKEW_SECS: u64 = 60;
81
82/// Resolved OAuth client identity (client ID + originator).
83///
84/// Both fields must be consistent: when a custom client ID is provided via
85/// `VTCODE_OPENAI_OAUTH_CLIENT_ID`, the originator must also be overridden
86/// via `VTCODE_OPENAI_OAUTH_ORIGINATOR`. Sending a custom client ID with
87/// Codex's `codex_cli_rs` originator (or vice versa) would be inconsistent
88/// and is rejected.
89///
90/// `Debug` is safe to derive: the client ID is a public PKCE client
91/// identifier (not a secret by OAuth 2.1 design), and the originator is
92/// a public identifier string.
93#[derive(Debug)]
94struct OAuthClientIdentity {
95    client_id: String,
96    originator: String,
97}
98
99/// Resolve the OAuth client identity from environment variables.
100///
101/// ## Invariant
102///
103/// The client ID and originator form a **coherent pair**. One-sided overrides
104/// are rejected to prevent mixed identities (e.g. a custom client ID paired
105/// with Codex's `codex_cli_rs` originator).
106///
107/// - Both `VTCODE_OPENAI_OAUTH_CLIENT_ID` and `VTCODE_OPENAI_OAUTH_ORIGINATOR`
108///   set and non-blank → use the custom pair.
109/// - Neither set → use the complete Codex default pair.
110/// - Only one set → return a configuration error with an actionable message.
111///   The caller must surface this so the user can fix the environment before
112///   any OAuth request is sent.
113///
114/// All four flow stages (authorization URL, code exchange, refresh, token
115/// exchange) call this resolver, so the same coherent pair is used throughout.
116fn resolve_oauth_client_identity() -> Result<OAuthClientIdentity> {
117    let custom_client_id = std::env::var("VTCODE_OPENAI_OAUTH_CLIENT_ID")
118        .ok()
119        .filter(|v| !v.trim().is_empty());
120    let custom_originator = std::env::var("VTCODE_OPENAI_OAUTH_ORIGINATOR")
121        .ok()
122        .filter(|v| !v.trim().is_empty());
123
124    match (custom_client_id, custom_originator) {
125        (Some(id), Some(originator)) => Ok(OAuthClientIdentity { client_id: id, originator }),
126        (Some(_), None) => bail!(
127            "VTCODE_OPENAI_OAUTH_CLIENT_ID is set but VTCODE_OPENAI_OAUTH_ORIGINATOR is not. \
128             The client ID and originator must be overridden together to form a coherent OAuth \
129             identity. Set VTCODE_OPENAI_OAUTH_ORIGINATOR to match your custom client ID, \
130             or unset VTCODE_OPENAI_OAUTH_CLIENT_ID to use the default Codex identity."
131        ),
132        (None, Some(_)) => bail!(
133            "VTCODE_OPENAI_OAUTH_ORIGINATOR is set but VTCODE_OPENAI_OAUTH_CLIENT_ID is not. \
134             The client ID and originator must be overridden together to form a coherent OAuth \
135             identity. Set VTCODE_OPENAI_OAUTH_CLIENT_ID to match your custom originator, \
136             or unset VTCODE_OPENAI_OAUTH_ORIGINATOR to use the default Codex identity."
137        ),
138        (None, None) => Ok(OAuthClientIdentity {
139            client_id: DEFAULT_OPENAI_CLIENT_ID.to_string(),
140            originator: DEFAULT_OPENAI_ORIGINATOR.to_string(),
141        }),
142    }
143}
144
145/// Stored OpenAI ChatGPT subscription session.
146///
147/// Custom `Debug` redacts all token fields to prevent credential leakage
148/// through `tracing::debug!(?session)` or error wrappers.
149#[derive(Clone, Serialize, Deserialize)]
150pub struct OpenAIChatGptSession {
151    /// Exchanged OpenAI bearer token used for normal API calls when available.
152    /// If unavailable, VT Code falls back to the OAuth access token.
153    pub openai_api_key: String,
154    /// OAuth ID token from the sign-in flow.
155    pub id_token: String,
156    /// OAuth access token from the sign-in flow.
157    pub access_token: String,
158    /// Refresh token used to renew the session.
159    pub refresh_token: String,
160    /// ChatGPT workspace/account identifier, if present.
161    pub account_id: Option<String>,
162    /// Account email, if present.
163    pub email: Option<String>,
164    /// ChatGPT plan type, if present.
165    pub plan: Option<String>,
166    /// When the session was originally created.
167    pub obtained_at: u64,
168    /// When the OAuth/API-key exchange was last refreshed.
169    pub refreshed_at: u64,
170    /// Access-token expiry, if supplied by the authority.
171    pub expires_at: Option<u64>,
172}
173
174impl fmt::Debug for OpenAIChatGptSession {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        f.debug_struct("OpenAIChatGptSession")
177            .field("openai_api_key", &"<redacted>")
178            .field("id_token", &"<redacted>")
179            .field("access_token", &"<redacted>")
180            .field("refresh_token", &"<redacted>")
181            .field("account_id", &self.account_id)
182            .field("email", &self.email)
183            .field("plan", &self.plan)
184            .field("obtained_at", &self.obtained_at)
185            .field("refreshed_at", &self.refreshed_at)
186            .field("expires_at", &self.expires_at)
187            .finish()
188    }
189}
190
191impl OpenAIChatGptSession {
192    fn is_refresh_due(&self) -> bool {
193        let now = now_secs();
194        if let Some(expires_at) = self.expires_at
195            && now.saturating_add(REFRESH_SKEW_SECS) >= expires_at
196        {
197            return true;
198        }
199        now.saturating_sub(self.refreshed_at) >= REFRESH_INTERVAL_SECS
200    }
201}
202
203/// Host-provided refresher for externally managed ChatGPT auth tokens.
204#[async_trait]
205pub trait OpenAIChatGptSessionRefresher: Send + Sync {
206    async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession>;
207}
208
209#[derive(Clone)]
210enum OpenAIChatGptAuthRefreshStrategy {
211    Stored {
212        storage_mode: AuthCredentialsStoreMode,
213    },
214    External {
215        refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
216    },
217}
218
219impl fmt::Debug for OpenAIChatGptAuthRefreshStrategy {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            Self::Stored { storage_mode } => f.debug_struct("Stored").field("storage_mode", storage_mode).finish(),
223            Self::External { .. } => f.debug_struct("External").finish_non_exhaustive(),
224        }
225    }
226}
227
228/// Runtime auth state shared by OpenAI provider instances.
229#[derive(Clone)]
230pub struct OpenAIChatGptAuthHandle {
231    session: Arc<Mutex<OpenAIChatGptSession>>,
232    refresh_gate: Arc<AsyncMutex<()>>,
233    auto_refresh: bool,
234    refresh_strategy: OpenAIChatGptAuthRefreshStrategy,
235}
236
237impl fmt::Debug for OpenAIChatGptAuthHandle {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        f.debug_struct("OpenAIChatGptAuthHandle")
240            .field("auto_refresh", &self.auto_refresh)
241            .field("refresh_strategy", &self.refresh_strategy)
242            .finish()
243    }
244}
245
246impl OpenAIChatGptAuthHandle {
247    pub fn new(
248        session: OpenAIChatGptSession,
249        auth_config: OpenAIAuthConfig,
250        storage_mode: AuthCredentialsStoreMode,
251    ) -> Self {
252        Self {
253            session: Arc::new(Mutex::new(session)),
254            refresh_gate: Arc::new(AsyncMutex::new(())),
255            auto_refresh: auth_config.auto_refresh,
256            refresh_strategy: OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode },
257        }
258    }
259
260    pub fn new_external(
261        session: OpenAIChatGptSession,
262        auto_refresh: bool,
263        refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
264    ) -> Self {
265        Self {
266            session: Arc::new(Mutex::new(session)),
267            refresh_gate: Arc::new(AsyncMutex::new(())),
268            auto_refresh,
269            refresh_strategy: OpenAIChatGptAuthRefreshStrategy::External { refresher },
270        }
271    }
272
273    pub fn snapshot(&self) -> Result<OpenAIChatGptSession> {
274        self.session
275            .lock()
276            .map(|guard| guard.clone())
277            .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))
278    }
279
280    pub fn current_api_key(&self) -> Result<String> {
281        self.snapshot().map(|session| active_api_bearer_token(&session).to_string())
282    }
283
284    pub fn provider_label(&self) -> &'static str {
285        "OpenAI (ChatGPT)"
286    }
287
288    pub async fn refresh_if_needed(&self) -> Result<()> {
289        if !self.auto_refresh {
290            return Ok(());
291        }
292
293        self.refresh_when(|session| session.is_refresh_due()).await
294    }
295
296    pub async fn force_refresh(&self) -> Result<()> {
297        self.refresh_when(|_| true).await
298    }
299
300    async fn refresh_when<P>(&self, should_refresh: P) -> Result<()>
301    where
302        P: FnOnce(&OpenAIChatGptSession) -> bool,
303    {
304        let _refresh_guard = self.refresh_gate.lock().await;
305        let session = self.snapshot()?;
306        if !should_refresh(&session) {
307            return Ok(());
308        }
309
310        let refreshed = match &self.refresh_strategy {
311            OpenAIChatGptAuthRefreshStrategy::Stored { storage_mode } => {
312                refresh_openai_chatgpt_session_from_snapshot(&session, *storage_mode).await?
313            }
314            OpenAIChatGptAuthRefreshStrategy::External { refresher } => refresher.refresh_session(&session).await?,
315        };
316        self.replace_session(refreshed)
317    }
318
319    #[must_use]
320    fn using_external_tokens(&self) -> bool {
321        matches!(self.refresh_strategy, OpenAIChatGptAuthRefreshStrategy::External { .. })
322    }
323
324    fn replace_session(&self, session: OpenAIChatGptSession) -> Result<()> {
325        let mut guard = self.session.lock().map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))?;
326        *guard = session;
327        Ok(())
328    }
329}
330
331/// OpenAI auth resolution chosen for the current runtime.
332///
333/// Custom `Debug` redacts the bearer `api_key` to prevent credential leakage.
334#[derive(Clone)]
335pub enum OpenAIResolvedAuth {
336    ApiKey {
337        api_key: String,
338    },
339    ChatGpt {
340        api_key: String,
341        handle: OpenAIChatGptAuthHandle,
342    },
343}
344
345impl fmt::Debug for OpenAIResolvedAuth {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        match self {
348            Self::ApiKey { .. } => f.debug_struct("OpenAIResolvedAuth::ApiKey").finish(),
349            Self::ChatGpt { .. } => f.debug_struct("OpenAIResolvedAuth::ChatGpt").finish(),
350        }
351    }
352}
353
354impl OpenAIResolvedAuth {
355    pub fn api_key(&self) -> &str {
356        match self {
357            Self::ApiKey { api_key } => api_key,
358            Self::ChatGpt { api_key, .. } => api_key,
359        }
360    }
361
362    pub fn handle(&self) -> Option<OpenAIChatGptAuthHandle> {
363        match self {
364            Self::ApiKey { .. } => None,
365            Self::ChatGpt { handle, .. } => Some(handle.clone()),
366        }
367    }
368
369    fn using_chatgpt(&self) -> bool {
370        matches!(self, Self::ChatGpt { .. })
371    }
372}
373
374fn active_api_bearer_token(session: &OpenAIChatGptSession) -> &str {
375    if session.openai_api_key.trim().is_empty() {
376        session.access_token.as_str()
377    } else {
378        session.openai_api_key.as_str()
379    }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub enum OpenAIResolvedAuthSource {
384    ApiKey,
385    ChatGpt,
386}
387
388/// Where the ChatGPT session originated — used by CLI/TUI to render accurate
389/// status without directly inspecting the filesystem.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum OpenAIChatGptSessionProvenance {
392    /// Session stored in VT Code's own credential storage (full auto-refresh).
393    Native,
394    /// Session loaded from Codex CLI's `~/.codex/auth.json` (managed by Codex).
395    CodexFallback,
396}
397
398/// Redacted summary of available OpenAI credentials for CLI/TUI display.
399///
400/// Does NOT carry token data — only metadata (email, plan, provenance, expiry)
401/// so credential values can never leak through `Debug` or logging.
402#[derive(Debug, Clone)]
403pub struct OpenAICredentialOverview {
404    pub api_key_available: bool,
405    /// Email from the ChatGPT session's ID token, if available.
406    pub chatgpt_email: Option<String>,
407    /// Plan type from the ChatGPT session's ID token, if available.
408    pub chatgpt_plan: Option<String>,
409    /// `true` when a ChatGPT session (native or Codex fallback) is available.
410    pub chatgpt_session_present: bool,
411    /// Provenance of the ChatGPT session — `None` when no session is available.
412    pub chatgpt_session_provenance: Option<OpenAIChatGptSessionProvenance>,
413    /// `true` only when Codex's auth.json was **successfully parsed** into a
414    /// usable session (not merely that the file exists on disk).
415    pub codex_fallback_available: bool,
416    pub active_source: Option<OpenAIResolvedAuthSource>,
417    pub preferred_method: OpenAIPreferredMethod,
418    pub notice: Option<String>,
419    pub recommendation: Option<String>,
420}
421
422/// Generic auth status reused by slash auth/status output.
423#[derive(Debug, Clone)]
424pub enum OpenAIChatGptAuthStatus {
425    Authenticated {
426        label: Option<String>,
427        age_seconds: u64,
428        expires_in: Option<u64>,
429    },
430    NotAuthenticated,
431}
432
433/// Build the OpenAI ChatGPT OAuth authorization URL.
434pub fn get_openai_chatgpt_auth_url(challenge: &PkceChallenge, callback_port: u16, state: &str) -> Result<String> {
435    let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
436    let identity = resolve_oauth_client_identity()?;
437    let query = [
438        ("response_type", "code".to_string()),
439        ("client_id", identity.client_id.clone()),
440        ("redirect_uri", redirect_uri),
441        ("scope", "openid profile email offline_access api.connectors.read api.connectors.invoke".to_string()),
442        ("code_challenge", challenge.code_challenge.clone()),
443        ("code_challenge_method", challenge.code_challenge_method.clone()),
444        ("id_token_add_organizations", "true".to_string()),
445        ("codex_cli_simplified_flow", "true".to_string()),
446        ("state", state.to_string()),
447        ("originator", identity.originator),
448    ];
449
450    let encoded = query
451        .iter()
452        .map(|(key, value)| format!("{key}={}", urlencoding::encode(value)))
453        .collect::<Vec<_>>()
454        .join("&");
455    Ok(format!("{OPENAI_AUTH_URL}?{encoded}"))
456}
457
458pub fn generate_openai_oauth_state() -> Result<String> {
459    let mut state_bytes = [0_u8; 32];
460    SystemRandom::new()
461        .fill(&mut state_bytes)
462        .map_err(|_| anyhow!("failed to generate openai oauth state"))?;
463    Ok(URL_SAFE_NO_PAD.encode(state_bytes))
464}
465
466pub fn parse_openai_chatgpt_manual_callback_input(input: &str, expected_state: &str) -> Result<String> {
467    let trimmed = input.trim();
468    if trimmed.is_empty() {
469        bail!("missing authorization callback input");
470    }
471
472    let query = if trimmed.contains("://") {
473        let url = reqwest::Url::parse(trimmed).context("invalid callback url")?;
474        url.query()
475            .ok_or_else(|| anyhow!("callback url did not include a query string"))?
476            .to_string()
477    } else if trimmed.contains('=') {
478        trimmed.trim_start_matches('?').to_string()
479    } else {
480        bail!("paste the full redirect url or query string containing code and state");
481    };
482
483    let code = extract_query_value(&query, "code")
484        .ok_or_else(|| anyhow!("callback input did not include an authorization code"))?;
485    let state = extract_query_value(&query, "state").ok_or_else(|| anyhow!("callback input did not include state"))?;
486    if state != expected_state {
487        bail!("OAuth error: state mismatch");
488    }
489    Ok(code)
490}
491
492/// Exchange an authorization code for OAuth tokens.
493pub async fn exchange_openai_chatgpt_code_for_tokens(
494    code: &str,
495    challenge: &PkceChallenge,
496    callback_port: u16,
497) -> Result<OpenAIChatGptSession> {
498    let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
499    let identity = resolve_oauth_client_identity()?;
500    let body = format!(
501        "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
502        urlencoding::encode(code),
503        urlencoding::encode(&redirect_uri),
504        urlencoding::encode(&identity.client_id),
505        urlencoding::encode(&challenge.code_verifier),
506    );
507
508    let token_response: OpenAITokenResponse = Client::new()
509        .post(OPENAI_TOKEN_URL)
510        .header("Content-Type", "application/x-www-form-urlencoded")
511        .body(body)
512        .send()
513        .await
514        .context("failed to exchange openai authorization code")?
515        .error_for_status()
516        .context("openai authorization-code exchange failed")?
517        .json()
518        .await
519        .context("failed to parse openai authorization-code response")?;
520
521    build_session_from_token_response(token_response).await
522}
523
524/// Resolve the active OpenAI auth source for the current configuration.
525pub fn resolve_openai_auth(
526    auth_config: &OpenAIAuthConfig,
527    storage_mode: AuthCredentialsStoreMode,
528    api_key: Option<String>,
529) -> Result<OpenAIResolvedAuth> {
530    crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode).resolve_runtime_auth(api_key)
531}
532
533pub fn summarize_openai_credentials(
534    auth_config: &OpenAIAuthConfig,
535    storage_mode: AuthCredentialsStoreMode,
536    api_key: Option<String>,
537) -> Result<OpenAICredentialOverview> {
538    crate::auth_service::OpenAIAccountAuthService::new(auth_config.clone(), storage_mode).summarize_credentials(api_key)
539}
540
541pub fn save_openai_chatgpt_session(session: &OpenAIChatGptSession) -> Result<()> {
542    save_openai_chatgpt_session_with_mode(session, AuthCredentialsStoreMode::default())
543}
544
545pub fn save_openai_chatgpt_session_with_mode(
546    session: &OpenAIChatGptSession,
547    mode: AuthCredentialsStoreMode,
548) -> Result<()> {
549    let serialized = serde_json::to_string(session).context("failed to serialize openai session")?;
550    match mode.effective_mode() {
551        AuthCredentialsStoreMode::Keyring => persist_session_to_keyring_or_file(session, &serialized)?,
552        AuthCredentialsStoreMode::File => save_session_to_file(session)?,
553        AuthCredentialsStoreMode::Auto => unreachable!(),
554    }
555    Ok(())
556}
557
558pub fn load_openai_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
559    load_preferred_openai_chatgpt_session(AuthCredentialsStoreMode::Keyring)
560}
561
562pub fn load_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenAIChatGptSession>> {
563    load_preferred_openai_chatgpt_session(mode.effective_mode())
564}
565
566pub fn clear_openai_chatgpt_session() -> Result<()> {
567    clear_session_from_all_stores()
568}
569
570pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
571    match mode.effective_mode() {
572        AuthCredentialsStoreMode::Keyring => clear_session_from_keyring(),
573        AuthCredentialsStoreMode::File => clear_session_from_file(),
574        AuthCredentialsStoreMode::Auto => unreachable!(),
575    }
576}
577
578pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
579    get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
580}
581
582pub fn get_openai_chatgpt_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptAuthStatus> {
583    let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
584        return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
585    };
586    let now = now_secs();
587    Ok(OpenAIChatGptAuthStatus::Authenticated {
588        label: session
589            .email
590            .clone()
591            .or_else(|| session.plan.clone())
592            .or_else(|| session.account_id.clone()),
593        age_seconds: now.saturating_sub(session.obtained_at),
594        expires_in: session.expires_at.map(|expires_at| expires_at.saturating_sub(now)),
595    })
596}
597
598/// Refresh a ChatGPT session using only a refresh token.
599///
600/// **Deprecated/unused:** This helper constructs a minimal session with blank
601/// token fields and relies on the refresh endpoint returning a complete
602/// response. It has no internal callers and is kept private to prevent
603/// external code from persisting sessions with blank sentinel values.
604/// Use `refresh_openai_chatgpt_session_with_mode` instead, which loads the
605/// full stored session first.
606async fn refresh_openai_chatgpt_session_from_refresh_token(
607    refresh_token: &str,
608    storage_mode: AuthCredentialsStoreMode,
609) -> Result<OpenAIChatGptSession> {
610    let _lock = acquire_refresh_lock().await?;
611    // Construct a minimal session from just the refresh token — used when
612    // the caller only has the refresh token (e.g. external integrations).
613    let minimal = OpenAIChatGptSession {
614        openai_api_key: String::new(),
615        id_token: String::new(),
616        access_token: String::new(),
617        refresh_token: refresh_token.to_string(),
618        account_id: None,
619        email: None,
620        plan: None,
621        obtained_at: 0,
622        refreshed_at: 0,
623        expires_at: None,
624    };
625    refresh_openai_chatgpt_session_without_lock(&minimal, storage_mode).await
626}
627
628pub async fn refresh_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptSession> {
629    let session = load_openai_chatgpt_session_with_mode(mode)?.ok_or_else(|| anyhow!("Run vtcode login openai"))?;
630    refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
631}
632
633async fn refresh_openai_chatgpt_session_from_snapshot(
634    session: &OpenAIChatGptSession,
635    storage_mode: AuthCredentialsStoreMode,
636) -> Result<OpenAIChatGptSession> {
637    let _lock = acquire_refresh_lock().await?;
638    if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
639        && session_has_newer_refresh_state(&current, session)
640    {
641        return Ok(current);
642    }
643    refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
644}
645
646/// Refresh the ChatGPT session using the stored refresh token.
647///
648/// The response is parsed as [`OpenAIRefreshResponse`] with independently
649/// optional fields — OpenAI's token endpoint may omit unchanged fields.
650/// Omitted fields preserve the current session's values. This matches the
651/// behavior of `openai/codex`'s `RefreshResponse` + `persist_tokens`.
652async fn refresh_openai_chatgpt_session_without_lock(
653    current: &OpenAIChatGptSession,
654    storage_mode: AuthCredentialsStoreMode,
655) -> Result<OpenAIChatGptSession> {
656    let identity = resolve_oauth_client_identity()?;
657    let response = Client::new()
658        .post(OPENAI_TOKEN_URL)
659        .header("Content-Type", "application/x-www-form-urlencoded")
660        .body(format!(
661            "grant_type=refresh_token&client_id={}&refresh_token={}",
662            urlencoding::encode(&identity.client_id),
663            urlencoding::encode(&current.refresh_token),
664        ))
665        .send()
666        .await
667        .context("failed to refresh openai chatgpt token")?;
668
669    // Check for HTTP errors. Unlike error_for_status_ref(), we capture the
670    // response body to classify token-endpoint errors (e.g. invalid_grant,
671    // refresh_token_expired) that reqwest's status-only error would miss.
672    if !response.status().is_success() {
673        let status = response.status();
674        // Read a bounded body for error classification — never log it raw.
675        let body_text = read_bounded_text(response, MAX_ERROR_BODY_BYTES).await;
676        return Err(classify_refresh_status_error(status, &body_text));
677    }
678
679    let refresh_response: OpenAIRefreshResponse =
680        response.json().await.context("failed to parse openai refresh response")?;
681
682    let session = merge_refresh_response(current, refresh_response).await?;
683    // Guard against a blank access_token — the primary bearer credential.
684    // This protects the minimal-session refresh helper (which starts with
685    // blank token fields) from persisting a session with blank tokens when
686    // the token endpoint returns a partial response that omits access_token.
687    if session.access_token.trim().is_empty() {
688        bail!("openai token refresh returned no access token — the session cannot be used");
689    }
690    save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
691    Ok(session)
692}
693
694/// Merge a partial refresh response into the current session, preserving
695/// omitted fields. Only re-exchanges the API key when a new `id_token` is
696/// present; otherwise keeps the previous exchanged key.
697async fn merge_refresh_response(
698    current: &OpenAIChatGptSession,
699    resp: OpenAIRefreshResponse,
700) -> Result<OpenAIChatGptSession> {
701    let now = now_secs();
702    // Track which fields were present before moving them out of resp.
703    // Treat blank-string values as absent — some token endpoints return
704    // empty strings for omitted fields rather than leaving them out.
705    let has_new_id_token = resp.id_token.as_deref().is_some_and(|v| !v.trim().is_empty());
706    let has_new_access_token = resp.access_token.as_deref().is_some_and(|v| !v.trim().is_empty());
707    let new_id_token = resp
708        .id_token
709        .filter(|v| !v.trim().is_empty())
710        .unwrap_or_else(|| current.id_token.clone());
711    let new_access_token = resp
712        .access_token
713        .filter(|v| !v.trim().is_empty())
714        .unwrap_or_else(|| current.access_token.clone());
715    let new_refresh_token = resp
716        .refresh_token
717        .filter(|v| !v.trim().is_empty())
718        .unwrap_or_else(|| current.refresh_token.clone());
719
720    // Re-exchange the API key only when a new id_token was provided.
721    let openai_api_key = if has_new_id_token {
722        match exchange_openai_chatgpt_api_key(&new_id_token).await {
723            Ok(api_key) => api_key,
724            Err(err) => {
725                tracing::warn!("openai api-key exchange unavailable, falling back to previous key: {err}");
726                current.openai_api_key.clone()
727            }
728        }
729    } else {
730        current.openai_api_key.clone()
731    };
732
733    // Recompute expiry: prefer expires_in from the response, then try to parse
734    // exp from the new access_token JWT. For a **changed** access token without
735    // expires_in or a parseable exp, set None — do NOT inherit the previous
736    // token's expiry, which belongs to a different token.
737    //
738    // Key distinction: `has_new_access_token` means the field was present and
739    // non-blank, NOT that the token value changed. If the endpoint repeats the
740    // same opaque access token without expires_in, the old expiry is still
741    // valid and must be preserved.
742    let access_token_changed = has_new_access_token && new_access_token != current.access_token;
743    let expires_at = if let Some(secs) = resp.expires_in {
744        Some(now.saturating_add(secs))
745    } else if access_token_changed {
746        parse_jwt_exp(&new_access_token)
747    } else {
748        current.expires_at
749    };
750
751    // Update email/plan/account_id only when a new id_token was provided.
752    let (email, plan, account_id) = if has_new_id_token {
753        let id_claims = parse_jwt_claims(&new_id_token)?;
754        let access_claims = parse_jwt_claims(&new_access_token).ok();
755        let email = id_claims.email.clone();
756        let plan = access_claims.as_ref().and_then(|c| c.plan.clone()).or(id_claims.plan);
757        let account_id = access_claims
758            .as_ref()
759            .and_then(|c| c.account_id.clone())
760            .or(id_claims.account_id);
761        (email, plan, account_id)
762    } else {
763        (current.email.clone(), current.plan.clone(), current.account_id.clone())
764    };
765
766    Ok(OpenAIChatGptSession {
767        openai_api_key,
768        id_token: new_id_token,
769        access_token: new_access_token,
770        refresh_token: new_refresh_token,
771        account_id,
772        email,
773        plan,
774        // Preserve the original obtained_at — only refreshed_at advances.
775        obtained_at: current.obtained_at,
776        refreshed_at: now,
777        expires_at,
778    })
779}
780
781async fn build_session_from_token_response(token_response: OpenAITokenResponse) -> Result<OpenAIChatGptSession> {
782    // Validate that the token response contains usable credentials.
783    if token_response.access_token.trim().is_empty() {
784        bail!("openai authorization-code response did not include a usable access token");
785    }
786    if token_response.refresh_token.trim().is_empty() {
787        bail!("openai authorization-code response did not include a usable refresh token");
788    }
789    let id_claims = parse_jwt_claims(&token_response.id_token)?;
790    let access_claims = parse_jwt_claims(&token_response.access_token).ok();
791    let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
792        Ok(api_key) => api_key,
793        Err(err) => {
794            tracing::warn!("openai api-key exchange unavailable, falling back to oauth access token: {err}");
795            String::new()
796        }
797    };
798    let now = now_secs();
799    Ok(OpenAIChatGptSession {
800        openai_api_key: api_key,
801        id_token: token_response.id_token,
802        access_token: token_response.access_token,
803        refresh_token: token_response.refresh_token,
804        account_id: access_claims
805            .as_ref()
806            .and_then(|claims| claims.account_id.clone())
807            .or(id_claims.account_id),
808        email: id_claims
809            .email
810            .or_else(|| access_claims.as_ref().and_then(|claims| claims.email.clone())),
811        plan: access_claims.as_ref().and_then(|claims| claims.plan.clone()).or(id_claims.plan),
812        obtained_at: now,
813        refreshed_at: now,
814        expires_at: token_response.expires_in.map(|secs| now.saturating_add(secs)),
815    })
816}
817
818async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
819    #[derive(Deserialize)]
820    struct ExchangeResponse {
821        access_token: String,
822    }
823
824    let identity = resolve_oauth_client_identity()?;
825    let exchange: ExchangeResponse = Client::new()
826        .post(OPENAI_TOKEN_URL)
827        .header("Content-Type", "application/x-www-form-urlencoded")
828        .body(format!(
829            "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
830            urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
831            urlencoding::encode(&identity.client_id),
832            urlencoding::encode("openai-api-key"),
833            urlencoding::encode(id_token),
834            urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
835        ))
836        .send()
837        .await
838        .context("failed to exchange openai id token for api key")?
839        .error_for_status()
840        .context("openai api-key exchange failed")?
841        .json()
842        .await
843        .context("failed to parse openai api-key exchange response")?;
844
845    Ok(exchange.access_token)
846}
847
848#[derive(Deserialize)]
849struct OpenAITokenResponse {
850    id_token: String,
851    access_token: String,
852    refresh_token: String,
853    #[serde(default)]
854    expires_in: Option<u64>,
855}
856
857/// Refresh-token grant response — all token fields are independently optional
858/// because OpenAI's token endpoint may omit unchanged fields (matching the
859/// behavior observed in `openai/codex`'s `RefreshResponse`). Omitted fields
860/// preserve the previous session's values during merge.
861#[derive(Deserialize)]
862struct OpenAIRefreshResponse {
863    #[serde(default)]
864    id_token: Option<String>,
865    #[serde(default)]
866    access_token: Option<String>,
867    #[serde(default)]
868    refresh_token: Option<String>,
869    #[serde(default)]
870    expires_in: Option<u64>,
871}
872
873#[derive(Debug, Deserialize)]
874struct IdTokenClaims {
875    #[serde(default)]
876    email: Option<String>,
877    #[serde(rename = "https://api.openai.com/profile", default)]
878    profile: Option<ProfileClaims>,
879    #[serde(rename = "https://api.openai.com/auth", default)]
880    auth: Option<AuthClaims>,
881}
882
883#[derive(Debug, Deserialize)]
884struct ProfileClaims {
885    #[serde(default)]
886    email: Option<String>,
887}
888
889#[derive(Debug, Deserialize)]
890struct AuthClaims {
891    #[serde(default)]
892    chatgpt_plan_type: Option<String>,
893    #[serde(default)]
894    chatgpt_account_id: Option<String>,
895}
896
897#[derive(Debug)]
898pub(crate) struct ParsedIdTokenClaims {
899    pub(crate) email: Option<String>,
900    pub(crate) account_id: Option<String>,
901    pub(crate) plan: Option<String>,
902}
903
904pub(crate) fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
905    let mut parts = jwt.split('.');
906    let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
907        (Some(header), Some(payload), Some(signature))
908            if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
909        {
910            (header, payload, signature)
911        }
912        _ => bail!("invalid openai id token"),
913    };
914
915    let payload = URL_SAFE_NO_PAD
916        .decode(payload_b64)
917        .context("failed to decode openai id token payload")?;
918    let claims: IdTokenClaims = serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
919
920    Ok(ParsedIdTokenClaims {
921        email: claims.email.or_else(|| claims.profile.and_then(|profile| profile.email)),
922        account_id: claims.auth.as_ref().and_then(|auth| auth.chatgpt_account_id.clone()),
923        plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
924    })
925}
926
927/// Extract the standard `exp` (expiry) claim from a JWT, if present.
928///
929/// Returns `None` when the token is not a JWT or has no `exp` claim.
930/// This is used to populate `expires_at` for Codex-imported sessions,
931/// since Codex's `auth.json` does not store expiry separately.
932pub(crate) fn parse_jwt_exp(jwt: &str) -> Option<u64> {
933    let mut parts = jwt.split('.');
934    let _ = parts.next()?;
935    let payload_b64 = parts.next()?;
936    if payload_b64.is_empty() {
937        return None;
938    }
939    let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
940    #[derive(Deserialize)]
941    struct ExpClaim {
942        #[serde(default)]
943        exp: Option<u64>,
944    }
945    let claims: ExpClaim = serde_json::from_slice(&payload).ok()?;
946    claims.exp
947}
948
949fn extract_query_value(query: &str, key: &str) -> Option<String> {
950    query
951        .trim_start_matches('?')
952        .split('&')
953        .filter_map(|pair| {
954            let (pair_key, pair_value) = pair.split_once('=')?;
955            (pair_key == key)
956                .then(|| urlencoding::decode(pair_value).ok().map(|value| value.into_owned()))
957                .flatten()
958        })
959        .find(|value| !value.is_empty())
960}
961
962fn session_has_newer_refresh_state(current: &OpenAIChatGptSession, previous: &OpenAIChatGptSession) -> bool {
963    current.refresh_token != previous.refresh_token
964        || current.refreshed_at > previous.refreshed_at
965        || current.obtained_at > previous.obtained_at
966}
967
968struct RefreshLockGuard {
969    file: fs::File,
970}
971
972impl Drop for RefreshLockGuard {
973    fn drop(&mut self) {
974        drop(FileExt::unlock(&self.file));
975    }
976}
977
978async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
979    let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
980    let file = OpenOptions::new()
981        .create(true)
982        .read(true)
983        .write(true)
984        .truncate(false)
985        .open(&path)
986        .context("failed to open openai refresh lock")?;
987    let file = tokio::task::spawn_blocking(move || {
988        file.lock_exclusive().context("failed to acquire openai refresh lock")?;
989        Ok::<_, anyhow::Error>(file)
990    })
991    .await
992    .context("openai refresh lock task failed")??;
993    Ok(RefreshLockGuard { file })
994}
995
996/// Read at most `max_bytes` from an HTTP response body as a string.
997///
998/// Reads the response in chunks and stops once `max_bytes` have been
999/// accumulated, preventing unbounded memory allocation from a misbehaving or
1000/// hostile endpoint. Invalid UTF-8 sequences are replaced (lossy) since we
1001/// only use the text for best-effort error classification, never for display
1002/// or logging.
1003async fn read_bounded_text(mut response: reqwest::Response, max_bytes: usize) -> String {
1004    let mut buf = Vec::with_capacity(max_bytes.min(8 * 1024));
1005    while buf.len() < max_bytes {
1006        match response.chunk().await {
1007            Ok(Some(chunk)) => {
1008                let remaining = max_bytes - buf.len();
1009                if chunk.len() <= remaining {
1010                    buf.extend_from_slice(&chunk);
1011                } else {
1012                    buf.extend_from_slice(chunk.get(..remaining).unwrap_or_default());
1013                    break;
1014                }
1015            }
1016            Ok(None) => break,
1017            Err(_) => break,
1018        }
1019    }
1020    String::from_utf8_lossy(&buf).into_owned()
1021}
1022
1023/// Extract the OAuth 2.0 error code from a token-endpoint error body.
1024///
1025/// Handles multiple JSON shapes that authorization servers use:
1026///
1027/// 1. **Flat form** (RFC 6749 §5.2): `{"error": "invalid_grant"}`
1028/// 2. **Nested object form**: `{"error": {"code": "invalid_grant", "message": "..."}}`
1029/// 3. **Nested with `type`** (some providers): `{"error": {"type": "invalid_grant", "message": "..."}}`
1030/// 4. **Top-level `code`** (some providers): `{"code": "invalid_grant", "message": "..."}`
1031///
1032/// Returns the matched error code (lowercased for case-insensitive matching)
1033/// or an empty string when the body cannot be parsed. Matching is exact
1034/// (normalized to lowercase) — never a substring match on free-text messages.
1035fn extract_error_code(body: &str) -> String {
1036    // Flat form: { "error": "invalid_grant" }
1037    #[derive(Deserialize)]
1038    struct FlatErrorResponse {
1039        #[serde(default)]
1040        error: Option<serde_json::Value>,
1041        // Some providers put the code at the top level instead of under "error".
1042        #[serde(default)]
1043        code: Option<String>,
1044    }
1045
1046    if let Ok(parsed) = serde_json::from_str::<FlatErrorResponse>(body) {
1047        // If "error" is a string, use it directly.
1048        if let Some(serde_json::Value::String(s)) = &parsed.error
1049            && !s.trim().is_empty()
1050        {
1051            return s.to_lowercase();
1052        }
1053
1054        // If "error" is an object, extract the code from the structured
1055        // "code" or "type" field only. We deliberately do NOT fall back to
1056        // the free-text "message" field — a descriptive message that happens
1057        // to contain a terminal code string (e.g. "invalid_grant") must not
1058        // be treated as a structured terminal code, per the exact-match
1059        // contract.
1060        if let Some(serde_json::Value::Object(obj)) = &parsed.error {
1061            let code = obj
1062                .get("code")
1063                .or_else(|| obj.get("type"))
1064                .and_then(|v| v.as_str())
1065                .filter(|s| !s.trim().is_empty());
1066            if let Some(c) = code {
1067                return c.to_lowercase();
1068            }
1069        }
1070
1071        // Top-level "code" field (not under "error").
1072        if let Some(code) = &parsed.code
1073            && !code.trim().is_empty()
1074        {
1075            return code.to_lowercase();
1076        }
1077    }
1078
1079    String::new()
1080}
1081
1082/// Classify a non-success response from the OpenAI token endpoint during a
1083/// refresh-token grant.
1084///
1085/// ## Terminal vs. transient classification
1086///
1087/// **Terminal** — the refresh token itself is no longer usable. Stored
1088/// credentials are cleared so the user sees a clear "re-login" message rather
1089/// than repeated silent failures.
1090///
1091/// Confirmed terminal grant error codes (matched exactly, across both 400 and
1092/// 401 token-endpoint responses):
1093///
1094/// | Code                        | Meaning                                         |
1095/// |-----------------------------|-------------------------------------------------|
1096/// | `invalid_grant`             | Refresh token expired, revoked, or invalid      |
1097/// | `invalid_token`             | Token is malformed or no longer valid           |
1098/// | `refresh_token_expired`     | Explicit refresh-token expiry (OpenAI variant)   |
1099/// | `refresh_token_revoked`     | Explicit refresh-token revocation                |
1100/// | `refresh_token_reused`      | Refresh token was used concurrently (single-use) |
1101/// | `refresh_token_invalidated` | Refresh token was explicitly invalidated         |
1102///
1103/// **Transient / configuration** — the session is preserved so the user can
1104/// retry or fix configuration without re-authenticating:
1105///
1106/// - `invalid_client` — bad custom client ID; the refresh token may still be
1107///   valid once the client ID is corrected.
1108/// - HTTP 5xx — server-side error, likely temporary.
1109/// - HTTP 429 — throttling; retry with backoff.
1110/// - Ambiguous 401 without a confirmed terminal code — could be a transient
1111///   auth issue or client-configuration problem.
1112///
1113/// The raw response body is never included in the returned error to avoid
1114/// leaking sensitive endpoint diagnostics.
1115#[cold]
1116fn classify_refresh_status_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
1117    let error_code = extract_error_code(body);
1118
1119    // Only these exact codes indicate the refresh token itself is no longer
1120    // usable. We match exactly (not substring) to avoid false positives from
1121    // descriptive messages that happen to contain these words.
1122    const TERMINAL_GRANT_CODES: &[&str] = &[
1123        "invalid_grant",
1124        "invalid_token",
1125        "refresh_token_expired",
1126        "refresh_token_revoked",
1127        "refresh_token_reused",
1128        "refresh_token_invalidated",
1129    ];
1130
1131    // Terminal grant errors can appear on both 400 and 401 token-endpoint
1132    // responses. We do NOT clear for `invalid_client` (client config issue),
1133    // server errors, throttling, or ambiguous 401s without a confirmed code.
1134    let is_client_error = status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::UNAUTHORIZED;
1135    let is_terminal_grant =
1136        is_client_error && error_code != String::new() && TERMINAL_GRANT_CODES.iter().any(|code| error_code == *code);
1137
1138    if is_terminal_grant {
1139        if let Err(clear_err) = clear_session_from_all_stores() {
1140            tracing::warn!("failed to clear expired openai chatgpt session across all stores: {clear_err}");
1141        }
1142        anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
1143    } else if error_code == "invalid_client" {
1144        // Client configuration error — the refresh token may still be valid
1145        // once the OAuth client ID/originator is corrected. Preserve the session.
1146        anyhow!(
1147            "openai token refresh failed (HTTP {status}, invalid_client) — \
1148             check your VTCODE_OPENAI_OAUTH_CLIENT_ID / VTCODE_OPENAI_OAUTH_ORIGINATOR configuration"
1149        )
1150    } else if status == reqwest::StatusCode::UNAUTHORIZED {
1151        // 401 without a confirmed terminal code is ambiguous — it could be
1152        // a transient auth issue or a client-configuration problem. Preserve
1153        // the session so the user can retry or fix config without losing auth.
1154        anyhow!("openai token refresh failed (HTTP {status}) — check your OAuth client configuration and retry")
1155    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1156        // Throttling — transient, preserve session for retry with backoff.
1157        anyhow!("openai token refresh was rate-limited (HTTP {status}) — retry later")
1158    } else {
1159        // Server errors (5xx) and other non-terminal 4xx — transient.
1160        anyhow!("openai token refresh failed (HTTP {status})")
1161    }
1162}
1163
1164fn clear_session_from_all_stores() -> Result<()> {
1165    let mut errors = Vec::new();
1166
1167    if let Err(err) = clear_session_from_keyring() {
1168        errors.push(err.to_string());
1169    }
1170    if let Err(err) = clear_session_from_file() {
1171        errors.push(err.to_string());
1172    }
1173
1174    if errors.is_empty() {
1175        Ok(())
1176    } else {
1177        Err(anyhow!("failed to clear openai session from all stores: {}", errors.join("; ")))
1178    }
1179}
1180
1181fn save_session_to_keyring(serialized: &str) -> Result<()> {
1182    let entry = keyring::entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
1183        .context("failed to access keyring for openai session")?;
1184    entry
1185        .set_password(serialized)
1186        .context("failed to store openai session in keyring")?;
1187    Ok(())
1188}
1189
1190fn persist_session_to_keyring_or_file(session: &OpenAIChatGptSession, serialized: &str) -> Result<()> {
1191    match save_session_to_keyring(serialized) {
1192        Ok(()) => match load_session_from_keyring_decoded() {
1193            Ok(Some(_)) => Ok(()),
1194            Ok(None) => {
1195                tracing::warn!(
1196                    "openai session keyring write did not round-trip; falling back to encrypted file storage"
1197                );
1198                save_session_to_file(session)
1199            }
1200            Err(err) => {
1201                tracing::warn!(
1202                    "openai session keyring verification failed, falling back to encrypted file storage: {err}"
1203                );
1204                save_session_to_file(session)
1205            }
1206        },
1207        Err(err) => {
1208            tracing::warn!(
1209                "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
1210            );
1211            save_session_to_file(session).context("failed to persist openai session after keyring fallback")
1212        }
1213    }
1214}
1215
1216fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
1217    serde_json::from_str(&serialized).context("failed to decode openai session")
1218}
1219
1220fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
1221    load_session_from_keyring()?.map(decode_session_from_keyring).transpose()
1222}
1223
1224fn load_preferred_openai_chatgpt_session(mode: AuthCredentialsStoreMode) -> Result<Option<OpenAIChatGptSession>> {
1225    match mode {
1226        AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
1227            Ok(Some(session)) => Ok(Some(session)),
1228            Ok(None) => load_session_from_file(),
1229            Err(err) => {
1230                tracing::warn!("failed to load openai session from keyring, falling back to encrypted file: {err}");
1231                load_session_from_file()
1232            }
1233        },
1234        AuthCredentialsStoreMode::File => {
1235            if let Some(session) = load_session_from_file()? {
1236                return Ok(Some(session));
1237            }
1238            load_session_from_keyring_decoded()
1239        }
1240        AuthCredentialsStoreMode::Auto => unreachable!(),
1241    }
1242}
1243
1244fn load_session_from_keyring() -> Result<Option<String>> {
1245    let entry = match keyring::entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
1246        Ok(entry) => entry,
1247        Err(_) => return Ok(None),
1248    };
1249
1250    match entry.get_password() {
1251        Ok(value) => Ok(Some(value)),
1252        Err(keyring_core::Error::NoEntry) => Ok(None),
1253        Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
1254    }
1255}
1256
1257fn clear_session_from_keyring() -> Result<()> {
1258    let entry = match keyring::entry(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
1259        Ok(entry) => entry,
1260        Err(_) => return Ok(()),
1261    };
1262
1263    match entry.delete_credential() {
1264        Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
1265        Err(err) => Err(anyhow!("failed to clear openai session keyring entry: {err}")),
1266    }
1267}
1268
1269fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
1270    let encrypted = encrypt_session(session)?;
1271    let path = get_session_path()?;
1272    let payload = serde_json::to_vec_pretty(&encrypted)?;
1273    write_private_file(&path, &payload).context("failed to persist openai session file")?;
1274    Ok(())
1275}
1276
1277fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
1278    let path = get_session_path()?;
1279    let data = match fs::read(path) {
1280        Ok(data) => data,
1281        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1282        Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
1283    };
1284
1285    let encrypted: EncryptedSession = serde_json::from_slice(&data).context("failed to decode openai session file")?;
1286    Ok(Some(decrypt_session(&encrypted)?))
1287}
1288
1289fn clear_session_from_file() -> Result<()> {
1290    let path = get_session_path()?;
1291    match fs::remove_file(path) {
1292        Ok(()) => Ok(()),
1293        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1294        Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
1295    }
1296}
1297
1298fn get_session_path() -> Result<PathBuf> {
1299    Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
1300}
1301
1302#[derive(Debug, Serialize, Deserialize)]
1303struct EncryptedSession {
1304    nonce: String,
1305    ciphertext: String,
1306    version: u8,
1307}
1308
1309fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
1310    let key = derive_encryption_key()?;
1311    let rng = SystemRandom::new();
1312    let mut nonce_bytes = [0u8; NONCE_LEN];
1313    rng.fill(&mut nonce_bytes).map_err(|_| anyhow!("failed to generate nonce"))?;
1314
1315    let mut ciphertext = serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
1316    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
1317    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
1318        .map_err(|_| anyhow!("failed to encrypt openai session"))?;
1319
1320    Ok(EncryptedSession {
1321        nonce: STANDARD.encode(nonce_bytes),
1322        ciphertext: STANDARD.encode(ciphertext),
1323        version: 1,
1324    })
1325}
1326
1327fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
1328    if encrypted.version != 1 {
1329        bail!("unsupported openai session encryption format");
1330    }
1331
1332    let nonce_bytes = STANDARD
1333        .decode(&encrypted.nonce)
1334        .context("failed to decode openai session nonce")?;
1335    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
1336        .try_into()
1337        .map_err(|_| anyhow!("invalid openai session nonce length"))?;
1338    let mut ciphertext = STANDARD
1339        .decode(&encrypted.ciphertext)
1340        .context("failed to decode openai session ciphertext")?;
1341
1342    let key = derive_encryption_key()?;
1343    let plaintext = key
1344        .open_in_place(Nonce::assume_unique_for_key(nonce_array), Aad::empty(), &mut ciphertext)
1345        .map_err(|_| anyhow!("failed to decrypt openai session"))?;
1346    serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
1347}
1348
1349fn derive_encryption_key() -> Result<LessSafeKey> {
1350    use ring::digest::{SHA256, digest};
1351
1352    let mut key_material = Vec::new();
1353    if let Ok(hostname) = hostname::get() {
1354        key_material.extend_from_slice(hostname.as_encoded_bytes());
1355    }
1356
1357    #[cfg(unix)]
1358    {
1359        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
1360    }
1361    #[cfg(not(unix))]
1362    {
1363        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
1364            key_material.extend_from_slice(user.as_bytes());
1365        }
1366    }
1367
1368    key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
1369    let hash = digest(&SHA256, &key_material);
1370    let key_bytes: &[u8; 32] = hash
1371        .as_ref()
1372        .get(..32)
1373        .context("openai session encryption key was too short")?
1374        .try_into()
1375        .context("openai session encryption key had an invalid length")?;
1376    let unbound =
1377        UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("invalid openai session encryption key"))?;
1378    Ok(LessSafeKey::new(unbound))
1379}
1380
1381fn now_secs() -> u64 {
1382    std::time::SystemTime::now()
1383        .duration_since(std::time::UNIX_EPOCH)
1384        .map(|duration| duration.as_secs())
1385        .unwrap_or(0)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use crate::AuthCallbackOutcome;
1392    use crate::generate_pkce_challenge;
1393    use assert_fs::TempDir;
1394    use serial_test::serial;
1395    use std::sync::Arc;
1396
1397    struct ExternalRefresher;
1398
1399    #[async_trait]
1400    impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1401        async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1402            let mut refreshed = current.clone();
1403            refreshed.access_token = "oauth-access-refreshed".to_string();
1404            refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1405            refreshed.expires_at = Some(now_secs() + 3600);
1406            Ok(refreshed)
1407        }
1408    }
1409
1410    struct TestAuthDirGuard {
1411        temp_dir: Option<TempDir>,
1412        codex_temp_dir: Option<TempDir>,
1413        previous: Option<PathBuf>,
1414        previous_codex_home: Option<String>,
1415    }
1416
1417    impl TestAuthDirGuard {
1418        fn new() -> Self {
1419            let temp_dir = TempDir::new().expect("create temp auth dir");
1420            let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
1421            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
1422                .expect("set temp auth dir override");
1423
1424            // Isolate CODEX_HOME so the Codex auth.json fallback doesn't pick
1425            // up a real Codex session from the user's machine during tests.
1426            let codex_temp_dir = TempDir::new().expect("create temp codex home");
1427            let previous_codex_home = std::env::var("CODEX_HOME").ok();
1428            vtcode_commons::env_lock::set_var("CODEX_HOME", codex_temp_dir.path());
1429
1430            Self {
1431                temp_dir: Some(temp_dir),
1432                codex_temp_dir: Some(codex_temp_dir),
1433                previous,
1434                previous_codex_home,
1435            }
1436        }
1437    }
1438
1439    impl Drop for TestAuthDirGuard {
1440        fn drop(&mut self) {
1441            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1442                .expect("restore auth dir override");
1443            if let Some(temp_dir) = self.temp_dir.take() {
1444                temp_dir.close().expect("remove temp auth dir");
1445            }
1446            vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", self.previous_codex_home.as_deref());
1447            if let Some(codex_temp_dir) = self.codex_temp_dir.take() {
1448                codex_temp_dir.close().expect("remove temp codex home");
1449            }
1450        }
1451    }
1452
1453    fn sample_session() -> OpenAIChatGptSession {
1454        OpenAIChatGptSession {
1455            openai_api_key: "api-key".to_string(),
1456            id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1457            access_token: "oauth-access".to_string(),
1458            refresh_token: "refresh-token".to_string(),
1459            account_id: Some("acc_123".to_string()),
1460            email: Some("test@example.com".to_string()),
1461            plan: Some("plus".to_string()),
1462            obtained_at: 10,
1463            refreshed_at: 10,
1464            expires_at: Some(now_secs() + 3600),
1465        }
1466    }
1467
1468    #[test]
1469    fn auth_url_contains_expected_openai_parameters() {
1470        // RAII guard locks env and restores both vars on drop (panic-safe).
1471        let env = OauthEnvGuard::new();
1472        env.remove_client_id();
1473        env.remove_originator();
1474
1475        let challenge = PkceChallenge {
1476            code_verifier: "verifier".to_string(),
1477            code_challenge: "challenge".to_string(),
1478            code_challenge_method: "S256".to_string(),
1479        };
1480
1481        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1482        assert!(url.starts_with(OPENAI_AUTH_URL));
1483        assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1484        assert!(url.contains("code_challenge=challenge"));
1485        assert!(url.contains("codex_cli_simplified_flow=true"));
1486        assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1487        assert!(url.contains("state=test-state"));
1488    }
1489
1490    #[test]
1491    fn auth_url_honors_custom_client_id_env_override() {
1492        // RAII guard locks env and restores both vars on drop (panic-safe).
1493        let env = OauthEnvGuard::new();
1494        env.set_client_id("app_custom_override");
1495        env.set_originator("vtcode_custom");
1496
1497        let challenge = PkceChallenge {
1498            code_verifier: "verifier".to_string(),
1499            code_challenge: "challenge".to_string(),
1500            code_challenge_method: "S256".to_string(),
1501        };
1502        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1503        assert!(url.contains("client_id=app_custom_override"), "custom client_id not used: {url}");
1504        assert!(url.contains("originator=vtcode_custom"), "custom originator not used: {url}");
1505        assert!(!url.contains("app_EMoamEEZ73f0CkXaXp7hrann"), "default client_id leaked through override: {url}");
1506    }
1507
1508    /// RAII guard that locks the environment and restores both OAuth identity
1509    /// env vars on drop — even if the test panics. This ensures parallel
1510    /// tests don't leak env mutations to each other.
1511    struct OauthEnvGuard {
1512        env: vtcode_commons::env_lock::EnvGuard,
1513        prev_client_id: Option<std::ffi::OsString>,
1514        prev_originator: Option<std::ffi::OsString>,
1515    }
1516
1517    impl OauthEnvGuard {
1518        fn new() -> Self {
1519            let env = vtcode_commons::env_lock::lock();
1520            let prev_client_id = std::env::var_os("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1521            let prev_originator = std::env::var_os("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1522            Self { env, prev_client_id, prev_originator }
1523        }
1524
1525        fn set_client_id(&self, value: &str) {
1526            self.env.set_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", value);
1527        }
1528
1529        fn set_originator(&self, value: &str) {
1530            self.env.set_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", value);
1531        }
1532
1533        fn remove_client_id(&self) {
1534            self.env.remove_var("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1535        }
1536
1537        fn remove_originator(&self) {
1538            self.env.remove_var("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1539        }
1540    }
1541
1542    impl Drop for OauthEnvGuard {
1543        fn drop(&mut self) {
1544            self.env
1545                .restore_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", self.prev_client_id.take());
1546            self.env
1547                .restore_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", self.prev_originator.take());
1548        }
1549    }
1550
1551    #[test]
1552    fn resolve_oauth_client_identity_both_defaults() {
1553        let env = OauthEnvGuard::new();
1554        env.remove_client_id();
1555        env.remove_originator();
1556
1557        let identity = resolve_oauth_client_identity().expect("defaults");
1558        assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1559        assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1560    }
1561
1562    #[test]
1563    fn resolve_oauth_client_identity_both_custom() {
1564        let env = OauthEnvGuard::new();
1565        env.set_client_id("app_custom");
1566        env.set_originator("my_originator");
1567
1568        let identity = resolve_oauth_client_identity().expect("custom pair");
1569        assert_eq!(identity.client_id, "app_custom");
1570        assert_eq!(identity.originator, "my_originator");
1571    }
1572
1573    #[test]
1574    fn resolve_oauth_client_identity_only_client_id_is_error() {
1575        let env = OauthEnvGuard::new();
1576        env.set_client_id("app_custom");
1577        env.remove_originator();
1578
1579        let err = resolve_oauth_client_identity().unwrap_err();
1580        let msg = err.to_string();
1581        assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the set var: {msg}");
1582        assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the missing var: {msg}");
1583    }
1584
1585    #[test]
1586    fn resolve_oauth_client_identity_only_originator_is_error() {
1587        let env = OauthEnvGuard::new();
1588        env.remove_client_id();
1589        env.set_originator("my_originator");
1590
1591        let err = resolve_oauth_client_identity().unwrap_err();
1592        let msg = err.to_string();
1593        assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the set var: {msg}");
1594        assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the missing var: {msg}");
1595    }
1596
1597    #[test]
1598    fn resolve_oauth_client_identity_blank_values_treated_as_unset() {
1599        let env = OauthEnvGuard::new();
1600        env.set_client_id("   ");
1601        env.set_originator("  ");
1602
1603        // Blank values are treated as unset → defaults used.
1604        let identity = resolve_oauth_client_identity().expect("defaults from blank");
1605        assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1606        assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1607    }
1608
1609    #[test]
1610    fn parse_jwt_claims_extracts_openai_claims() {
1611        let claims = parse_jwt_claims(
1612            "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1613        )
1614        .expect("claims");
1615        assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1616        assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1617        assert_eq!(claims.plan.as_deref(), Some("plus"));
1618    }
1619
1620    #[test]
1621    fn session_refresh_due_uses_expiry_and_age() {
1622        let mut session = sample_session();
1623        let now = now_secs();
1624        session.obtained_at = now;
1625        session.refreshed_at = now;
1626        session.expires_at = Some(now + 3600);
1627        assert!(!session.is_refresh_due());
1628        session.expires_at = Some(now);
1629        assert!(session.is_refresh_due());
1630    }
1631
1632    #[tokio::test]
1633    #[serial]
1634    async fn external_auth_handle_refreshes_without_persisting_session() {
1635        let _guard = TestAuthDirGuard::new();
1636        let mut session = sample_session();
1637        session.openai_api_key.clear();
1638        session.expires_at = Some(now_secs().saturating_sub(1));
1639        let handle = OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1640
1641        assert!(handle.using_external_tokens());
1642        assert!(
1643            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1644                .expect("load session")
1645                .is_none()
1646        );
1647
1648        handle.force_refresh().await.expect("force refresh");
1649
1650        assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1651        assert!(
1652            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1653                .expect("load session")
1654                .is_none()
1655        );
1656    }
1657
1658    struct CountingExternalRefresher {
1659        calls: Arc<Mutex<usize>>,
1660    }
1661
1662    #[async_trait]
1663    impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1664        async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1665            let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1666            *calls += 1;
1667            drop(calls);
1668
1669            let mut refreshed = current.clone();
1670            refreshed.access_token = "oauth-access-refreshed".to_string();
1671            refreshed.refreshed_at = now_secs();
1672            refreshed.expires_at = Some(now_secs() + 3600);
1673            Ok(refreshed)
1674        }
1675    }
1676
1677    #[tokio::test]
1678    async fn refresh_if_needed_serializes_external_refreshes() {
1679        let mut session = sample_session();
1680        session.openai_api_key.clear();
1681        session.expires_at = Some(now_secs().saturating_sub(1));
1682        let calls = Arc::new(Mutex::new(0usize));
1683        let handle = OpenAIChatGptAuthHandle::new_external(
1684            session,
1685            true,
1686            Arc::new(CountingExternalRefresher { calls: Arc::clone(&calls) }),
1687        );
1688
1689        let first = handle.clone();
1690        let second = handle.clone();
1691        let (first_result, second_result) = tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1692
1693        first_result.expect("first refresh should succeed");
1694        second_result.expect("second refresh should succeed");
1695        assert_eq!(
1696            *calls.lock().expect("refresh calls mutex should lock"),
1697            1,
1698            "concurrent refresh_if_needed calls should share one refresh"
1699        );
1700        assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1701    }
1702
1703    #[test]
1704    #[serial]
1705    fn resolve_openai_auth_prefers_chatgpt_in_auto_permission() {
1706        let _guard = TestAuthDirGuard::new();
1707        let session = sample_session();
1708        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1709        let resolved = resolve_openai_auth(
1710            &OpenAIAuthConfig::default(),
1711            AuthCredentialsStoreMode::File,
1712            Some("api-key".to_string()),
1713        )
1714        .expect("resolved auth");
1715        assert!(resolved.using_chatgpt());
1716        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1717    }
1718
1719    #[test]
1720    #[serial]
1721    #[cfg(unix)]
1722    fn file_storage_uses_private_permissions() {
1723        use std::fs;
1724        use std::os::unix::fs::PermissionsExt;
1725
1726        let _guard = TestAuthDirGuard::new();
1727        let session = sample_session();
1728
1729        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1730
1731        let metadata = fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1732        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1733    }
1734
1735    #[test]
1736    #[serial]
1737    fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1738        let _guard = TestAuthDirGuard::new();
1739        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1740        let resolved = resolve_openai_auth(
1741            &OpenAIAuthConfig::default(),
1742            AuthCredentialsStoreMode::File,
1743            Some("api-key".to_string()),
1744        )
1745        .expect("resolved auth");
1746        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1747    }
1748
1749    #[test]
1750    #[serial]
1751    fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1752        let _guard = TestAuthDirGuard::new();
1753        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1754        let error =
1755            resolve_openai_auth(&OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File, Some("   ".to_string()))
1756                .expect_err("blank api key should fail");
1757        assert!(error.to_string().contains("OpenAI API key not found"));
1758    }
1759
1760    #[test]
1761    #[serial]
1762    fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1763        let _guard = TestAuthDirGuard::new();
1764        let session = sample_session();
1765        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1766        let resolved = resolve_openai_auth(
1767            &OpenAIAuthConfig {
1768                preferred_method: OpenAIPreferredMethod::ApiKey,
1769                ..OpenAIAuthConfig::default()
1770            },
1771            AuthCredentialsStoreMode::File,
1772            Some("api-key".to_string()),
1773        )
1774        .expect("resolved auth");
1775        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1776        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1777    }
1778
1779    #[test]
1780    #[serial]
1781    fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1782        let _guard = TestAuthDirGuard::new();
1783        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1784        let error = resolve_openai_auth(
1785            &OpenAIAuthConfig {
1786                preferred_method: OpenAIPreferredMethod::Chatgpt,
1787                ..OpenAIAuthConfig::default()
1788            },
1789            AuthCredentialsStoreMode::File,
1790            Some("api-key".to_string()),
1791        )
1792        .expect_err("chatgpt mode should require a stored session");
1793        assert!(error.to_string().contains("vtcode login openai"));
1794    }
1795
1796    #[test]
1797    #[serial]
1798    fn summarize_openai_credentials_reports_dual_source_notice() {
1799        let _guard = TestAuthDirGuard::new();
1800        let session = sample_session();
1801        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1802        let overview = summarize_openai_credentials(
1803            &OpenAIAuthConfig::default(),
1804            AuthCredentialsStoreMode::File,
1805            Some("api-key".to_string()),
1806        )
1807        .expect("overview");
1808        assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ChatGpt));
1809        assert!(overview.notice.is_some());
1810        assert!(overview.recommendation.is_some());
1811        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1812    }
1813
1814    #[test]
1815    #[serial]
1816    fn summarize_openai_credentials_respects_api_key_preference() {
1817        let _guard = TestAuthDirGuard::new();
1818        let session = sample_session();
1819        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1820        let overview = summarize_openai_credentials(
1821            &OpenAIAuthConfig {
1822                preferred_method: OpenAIPreferredMethod::ApiKey,
1823                ..OpenAIAuthConfig::default()
1824            },
1825            AuthCredentialsStoreMode::File,
1826            Some("api-key".to_string()),
1827        )
1828        .expect("overview");
1829        assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ApiKey));
1830        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1831    }
1832
1833    #[test]
1834    fn encrypted_file_round_trip_restores_session() {
1835        let session = sample_session();
1836        let encrypted = encrypt_session(&session).expect("encrypt");
1837        let decrypted = decrypt_session(&encrypted).expect("decrypt");
1838        assert_eq!(decrypted.account_id, session.account_id);
1839        assert_eq!(decrypted.email, session.email);
1840        assert_eq!(decrypted.plan, session.plan);
1841    }
1842
1843    #[test]
1844    #[serial]
1845    fn default_loader_falls_back_to_file_session() {
1846        let _guard = TestAuthDirGuard::new();
1847        let session = sample_session();
1848        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1849
1850        let loaded = load_openai_chatgpt_session()
1851            .expect("load session")
1852            .expect("stored session should be found");
1853
1854        assert_eq!(loaded.account_id, session.account_id);
1855        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1856    }
1857
1858    #[test]
1859    #[serial]
1860    fn keyring_mode_loader_falls_back_to_file_session() {
1861        let _guard = TestAuthDirGuard::new();
1862        let session = sample_session();
1863        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1864
1865        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1866            .expect("load session")
1867            .expect("stored session should be found");
1868
1869        assert_eq!(loaded.email, session.email);
1870        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1871    }
1872
1873    #[test]
1874    #[serial]
1875    fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1876        let _guard = TestAuthDirGuard::new();
1877        let session = sample_session();
1878        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save file session");
1879
1880        if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring).is_err() {
1881            clear_openai_chatgpt_session().expect("clear session");
1882            assert!(
1883                load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1884                    .expect("load file session")
1885                    .is_none()
1886            );
1887            return;
1888        }
1889
1890        clear_openai_chatgpt_session().expect("clear session");
1891        assert!(
1892            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1893                .expect("load file session")
1894                .is_none()
1895        );
1896        assert!(
1897            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1898                .expect("load keyring session")
1899                .is_none()
1900        );
1901    }
1902
1903    #[test]
1904    fn active_api_bearer_token_falls_back_to_access_token() {
1905        let mut session = sample_session();
1906        session.openai_api_key.clear();
1907
1908        assert_eq!(active_api_bearer_token(&session), "oauth-access");
1909    }
1910
1911    #[test]
1912    fn parse_manual_callback_input_accepts_full_redirect_url() {
1913        let code = parse_openai_chatgpt_manual_callback_input(
1914            "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1915            "test-state",
1916        )
1917        .expect("manual input should parse");
1918        assert_eq!(code, "auth-code");
1919    }
1920
1921    #[test]
1922    fn parse_manual_callback_input_accepts_query_string() {
1923        let code = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=test-state", "test-state")
1924            .expect("manual input should parse");
1925        assert_eq!(code, "auth-code");
1926    }
1927
1928    #[test]
1929    fn parse_manual_callback_input_rejects_bare_code() {
1930        let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1931            .expect_err("bare code should be rejected");
1932        assert!(error.to_string().contains("full redirect url or query string"));
1933    }
1934
1935    #[test]
1936    fn parse_manual_callback_input_rejects_state_mismatch() {
1937        let error = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=wrong-state", "test-state")
1938            .expect_err("state mismatch should fail");
1939        assert!(error.to_string().contains("state mismatch"));
1940    }
1941
1942    #[tokio::test]
1943    #[serial]
1944    async fn refresh_lock_serializes_parallel_acquisition() {
1945        let _guard = TestAuthDirGuard::new();
1946        let first = tokio::spawn(async {
1947            let _lock = acquire_refresh_lock().await.expect("first lock");
1948            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1949        });
1950        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1951
1952        let start = std::time::Instant::now();
1953        let second = tokio::spawn(async {
1954            let _lock = acquire_refresh_lock().await.expect("second lock");
1955        });
1956
1957        first.await.expect("first task");
1958        second.await.expect("second task");
1959        assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1960    }
1961
1962    // ── Debug redaction tests ──
1963
1964    #[test]
1965    fn debug_impl_redacts_all_token_fields() {
1966        let session = sample_session();
1967        let debug_str = format!("{session:?}");
1968        // None of the secret values may appear in the Debug output.
1969        assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
1970        assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
1971        assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
1972        // The id_token JWT body is long; check a distinctive substring.
1973        assert!(!debug_str.contains("eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20i"), "id_token leaked: {debug_str}");
1974        // Non-secret metadata should still be present.
1975        assert!(debug_str.contains("test@example.com"), "email should be visible: {debug_str}");
1976        assert!(debug_str.contains("plus"), "plan should be visible: {debug_str}");
1977    }
1978
1979    #[test]
1980    fn debug_impl_redacts_resolved_auth_api_key() {
1981        let resolved = OpenAIResolvedAuth::ApiKey { api_key: "sk-secret-key".to_string() };
1982        let debug_str = format!("{resolved:?}");
1983        assert!(!debug_str.contains("sk-secret-key"), "api_key leaked: {debug_str}");
1984    }
1985
1986    #[test]
1987    fn debug_impl_redacts_resolved_auth_chatgpt_handle() {
1988        let _guard = TestAuthDirGuard::new();
1989        let session = sample_session();
1990        let handle = OpenAIChatGptAuthHandle::new(session, OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File);
1991        let resolved = OpenAIResolvedAuth::ChatGpt { api_key: "sk-secret-bearer".to_string(), handle };
1992        let debug_str = format!("{resolved:?}");
1993        assert!(!debug_str.contains("sk-secret-bearer"), "bearer leaked: {debug_str}");
1994    }
1995
1996    #[test]
1997    fn credential_overview_carries_no_token_fields() {
1998        let _guard = TestAuthDirGuard::new();
1999        let session = sample_session();
2000        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2001        let overview = summarize_openai_credentials(
2002            &OpenAIAuthConfig::default(),
2003            AuthCredentialsStoreMode::File,
2004            Some("sk-overview-key".to_string()),
2005        )
2006        .expect("overview");
2007        // The overview is a display struct — verify it only carries metadata,
2008        // not the raw session or token strings.
2009        let debug_str = format!("{overview:?}");
2010        assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
2011        assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
2012        assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
2013        // The overview should not contain the raw API key value either.
2014        assert!(!debug_str.contains("sk-overview-key"), "api key value leaked: {debug_str}");
2015        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear");
2016    }
2017
2018    // ── Partial refresh response merge tests ──
2019    //
2020    // These test merge_refresh_response directly with resp.id_token = None,
2021    // which skips the HTTP API-key exchange (has_new_id_token = false).
2022    // This lets us verify field-preservation behavior without network access.
2023
2024    #[tokio::test]
2025    async fn merge_preserves_omitted_access_token() {
2026        let current = sample_session();
2027        let resp = OpenAIRefreshResponse {
2028            id_token: None,
2029            access_token: None,
2030            refresh_token: Some("new-refresh".to_string()),
2031            expires_in: Some(3600),
2032        };
2033        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2034        assert_eq!(merged.access_token, current.access_token, "omitted access_token should be preserved");
2035        assert_eq!(merged.refresh_token, "new-refresh");
2036    }
2037
2038    #[tokio::test]
2039    async fn merge_preserves_omitted_refresh_token() {
2040        let current = sample_session();
2041        let resp = OpenAIRefreshResponse {
2042            id_token: None,
2043            access_token: Some("new-access".to_string()),
2044            refresh_token: None,
2045            expires_in: None,
2046        };
2047        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2048        assert_eq!(merged.refresh_token, current.refresh_token, "omitted refresh_token should be preserved");
2049        assert_eq!(merged.access_token, "new-access");
2050    }
2051
2052    #[tokio::test]
2053    async fn merge_preserves_omitted_id_token_and_api_key() {
2054        let current = sample_session();
2055        let resp = OpenAIRefreshResponse {
2056            id_token: None,
2057            access_token: Some("new-access".to_string()),
2058            refresh_token: None,
2059            expires_in: Some(1800),
2060        };
2061        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2062        // No new id_token → old id_token and api_key preserved, no HTTP exchange.
2063        assert_eq!(merged.id_token, current.id_token, "omitted id_token should be preserved");
2064        assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key should be preserved without new id_token");
2065        // Email/plan/account_id also preserved without a new id_token.
2066        assert_eq!(merged.email, current.email);
2067        assert_eq!(merged.plan, current.plan);
2068    }
2069
2070    #[tokio::test]
2071    async fn merge_all_omitted_preserves_everything() {
2072        let current = sample_session();
2073        let resp = OpenAIRefreshResponse {
2074            id_token: None,
2075            access_token: None,
2076            refresh_token: None,
2077            expires_in: None,
2078        };
2079        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2080        assert_eq!(merged.id_token, current.id_token);
2081        assert_eq!(merged.access_token, current.access_token);
2082        assert_eq!(merged.refresh_token, current.refresh_token);
2083        assert_eq!(merged.openai_api_key, current.openai_api_key);
2084        assert_eq!(merged.email, current.email);
2085        // expires_in is None and no new access_token → old expiry preserved.
2086        assert_eq!(merged.expires_at, current.expires_at);
2087    }
2088
2089    #[tokio::test]
2090    async fn merge_new_access_token_updates_bearer_without_id_token() {
2091        let current = sample_session();
2092        let resp = OpenAIRefreshResponse {
2093            id_token: None,
2094            access_token: Some("replaced-access".to_string()),
2095            refresh_token: None,
2096            expires_in: Some(7200),
2097        };
2098        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2099        assert_eq!(merged.access_token, "replaced-access");
2100        // active_api_bearer_token should use the exchanged api_key (unchanged)
2101        // because no new id_token was provided.
2102        assert_eq!(active_api_bearer_token(&merged), current.openai_api_key);
2103    }
2104
2105    // ── Blank-string refresh field tests ──
2106    //
2107    // Some token endpoints return empty strings for omitted fields rather than
2108    // leaving them out. These verify that blank strings are treated as omitted.
2109
2110    #[tokio::test]
2111    async fn merge_treats_blank_access_token_as_omitted() {
2112        let current = sample_session();
2113        let resp = OpenAIRefreshResponse {
2114            id_token: None,
2115            access_token: Some("   ".to_string()),
2116            refresh_token: Some("new-refresh".to_string()),
2117            expires_in: Some(3600),
2118        };
2119        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2120        assert_eq!(merged.access_token, current.access_token, "blank access_token should be treated as omitted");
2121        assert_eq!(merged.refresh_token, "new-refresh");
2122    }
2123
2124    #[tokio::test]
2125    async fn merge_treats_blank_refresh_token_as_omitted() {
2126        let current = sample_session();
2127        let resp = OpenAIRefreshResponse {
2128            id_token: None,
2129            access_token: Some("new-access".to_string()),
2130            refresh_token: Some(String::new()),
2131            expires_in: None,
2132        };
2133        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2134        assert_eq!(merged.refresh_token, current.refresh_token, "blank refresh_token should be treated as omitted");
2135        assert_eq!(merged.access_token, "new-access");
2136    }
2137
2138    #[tokio::test]
2139    async fn merge_treats_blank_id_token_as_omitted() {
2140        let current = sample_session();
2141        let resp = OpenAIRefreshResponse {
2142            id_token: Some("  ".to_string()),
2143            access_token: Some("new-access".to_string()),
2144            refresh_token: None,
2145            expires_in: None,
2146        };
2147        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2148        // Blank id_token → treated as omitted → no HTTP exchange, old preserved.
2149        assert_eq!(merged.id_token, current.id_token, "blank id_token should be treated as omitted");
2150        assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key preserved when id_token is blank");
2151    }
2152
2153    // ── Opaque access token expiry tests ──
2154
2155    #[tokio::test]
2156    async fn merge_changed_opaque_access_token_clears_stale_expiry() {
2157        let mut current = sample_session();
2158        current.expires_at = Some(now_secs() + 3600); // old expiry for old token
2159
2160        // New access token without expires_in and without a parseable JWT exp.
2161        // "opaque-new-token" is not a JWT, so parse_jwt_exp returns None.
2162        let resp = OpenAIRefreshResponse {
2163            id_token: None,
2164            access_token: Some("opaque-new-token".to_string()),
2165            refresh_token: None,
2166            expires_in: None,
2167        };
2168        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2169        assert_eq!(merged.access_token, "opaque-new-token");
2170        // Changed token without expires_in or JWT exp → expiry must be None,
2171        // NOT the old token's expiry.
2172        assert_eq!(merged.expires_at, None, "changed opaque access token must not inherit old token's expiry");
2173    }
2174
2175    #[tokio::test]
2176    async fn merge_repeated_opaque_access_token_preserves_expiry() {
2177        let old_expiry = now_secs() + 3600;
2178        let mut current = sample_session();
2179        current.access_token = "opaque-same-token".to_string();
2180        current.expires_at = Some(old_expiry);
2181
2182        // The endpoint repeats the SAME opaque access token without expires_in.
2183        // Since the token didn't change, the old expiry is still valid.
2184        let resp = OpenAIRefreshResponse {
2185            id_token: None,
2186            access_token: Some("opaque-same-token".to_string()),
2187            refresh_token: Some("new-refresh".to_string()),
2188            expires_in: None,
2189        };
2190        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2191        assert_eq!(merged.access_token, "opaque-same-token");
2192        assert_eq!(merged.expires_at, Some(old_expiry), "repeated same opaque access token must preserve old expiry");
2193    }
2194
2195    #[tokio::test]
2196    async fn merge_omitted_access_token_preserves_old_expiry() {
2197        let old_expiry = now_secs() + 3600;
2198        let mut current = sample_session();
2199        current.expires_at = Some(old_expiry);
2200
2201        let resp = OpenAIRefreshResponse {
2202            id_token: None,
2203            access_token: None,
2204            refresh_token: Some("new-refresh".to_string()),
2205            expires_in: None,
2206        };
2207        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2208        // No new access_token and no expires_in → old expiry preserved.
2209        assert_eq!(merged.expires_at, Some(old_expiry), "omitted access_token should preserve old expiry");
2210    }
2211
2212    #[tokio::test]
2213    async fn merge_expires_in_overrides_for_omitted_access_token() {
2214        let old_expiry = now_secs() + 3600;
2215        let mut current = sample_session();
2216        current.expires_at = Some(old_expiry);
2217
2218        let resp = OpenAIRefreshResponse {
2219            id_token: None,
2220            access_token: None,
2221            refresh_token: None,
2222            expires_in: Some(1800),
2223        };
2224        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2225        // expires_in takes priority over old expiry even without a new access_token.
2226        assert_ne!(merged.expires_at, Some(old_expiry));
2227        assert!(merged.expires_at.is_some());
2228    }
2229
2230    // ── Error classification tests (pure, no network) ──
2231
2232    #[test]
2233    fn extract_error_code_flat_form() {
2234        assert_eq!(extract_error_code(r#"{"error": "invalid_grant"}"#), "invalid_grant");
2235    }
2236
2237    #[test]
2238    fn extract_error_code_nested_form() {
2239        let body = r#"{"error": {"code": "refresh_token_expired", "message": "token expired"}}"#;
2240        assert_eq!(extract_error_code(body), "refresh_token_expired");
2241    }
2242
2243    #[test]
2244    fn extract_error_code_nested_form_does_not_fall_back_to_message() {
2245        // A descriptive message that happens to contain a terminal code string
2246        // must NOT be treated as a structured error code. Only `code` and
2247        // `type` fields are recognized — `message` is free-text.
2248        let body = r#"{"error": {"message": "invalid_grant"}}"#;
2249        assert_eq!(extract_error_code(body), "", "message field should not be used as error code: {body}");
2250    }
2251
2252    #[test]
2253    fn extract_error_code_empty_body() {
2254        assert_eq!(extract_error_code(""), "");
2255    }
2256
2257    #[test]
2258    fn extract_error_code_non_json_body() {
2259        assert_eq!(extract_error_code("Internal Server Error"), "");
2260    }
2261
2262    #[test]
2263    fn extract_error_code_blank_error_field() {
2264        assert_eq!(extract_error_code(r#"{"error": "  "}"#), "");
2265    }
2266
2267    #[test]
2268    fn classify_refresh_status_error_invalid_grant_clears_session() {
2269        let _guard = TestAuthDirGuard::new();
2270        // Store a session so we can verify it gets cleared.
2271        let session = sample_session();
2272        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2273        assert!(
2274            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2275                .expect("load")
2276                .is_some()
2277        );
2278
2279        let err = classify_refresh_status_error(reqwest::StatusCode::BAD_REQUEST, r#"{"error": "invalid_grant"}"#);
2280        assert!(err.to_string().contains("session expired"));
2281
2282        // Session should have been cleared.
2283        assert!(
2284            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2285                .expect("load")
2286                .is_none()
2287        );
2288    }
2289
2290    #[test]
2291    fn classify_refresh_status_error_nested_refresh_token_expired_clears_session() {
2292        let _guard = TestAuthDirGuard::new();
2293        let session = sample_session();
2294        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2295
2296        let err = classify_refresh_status_error(
2297            reqwest::StatusCode::BAD_REQUEST,
2298            r#"{"error": {"code": "refresh_token_expired", "message": "The refresh token has expired"}}"#,
2299        );
2300        assert!(err.to_string().contains("session expired"));
2301
2302        assert!(
2303            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2304                .expect("load")
2305                .is_none()
2306        );
2307    }
2308
2309    #[test]
2310    fn classify_refresh_status_error_unauthorized_preserves_session() {
2311        let _guard = TestAuthDirGuard::new();
2312        let session = sample_session();
2313        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2314
2315        let err = classify_refresh_status_error(reqwest::StatusCode::UNAUTHORIZED, "");
2316        assert!(err.to_string().contains("HTTP 401"), "should report HTTP 401: {err}");
2317        // 401 without a confirmed terminal code preserves the session.
2318        assert!(
2319            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2320                .expect("load")
2321                .is_some(),
2322            "session should be preserved on ambiguous 401"
2323        );
2324    }
2325
2326    #[test]
2327    fn classify_refresh_status_error_server_error_does_not_clear_session() {
2328        let _guard = TestAuthDirGuard::new();
2329        let session = sample_session();
2330        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2331
2332        let err =
2333            classify_refresh_status_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, r#"{"error": "internal_error"}"#);
2334        assert!(err.to_string().contains("HTTP 500"));
2335        // Transient error → session preserved for retry.
2336        assert!(
2337            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2338                .expect("load")
2339                .is_some()
2340        );
2341    }
2342
2343    #[test]
2344    fn classify_refresh_status_error_never_includes_raw_body() {
2345        let _guard = TestAuthDirGuard::new();
2346        let err = classify_refresh_status_error(
2347            reqwest::StatusCode::BAD_REQUEST,
2348            r#"{"error": "invalid_grant", "sensitive_data": "secret-leak-attempt"}"#,
2349        );
2350        assert!(!err.to_string().contains("secret-leak-attempt"), "raw body leaked into error: {err}");
2351    }
2352
2353    // ── Table-driven classification matrix (status × body shape × expected) ──
2354
2355    /// Expected outcome of `classify_refresh_status_error`.
2356    #[derive(Debug, PartialEq)]
2357    enum ClassifyOutcome {
2358        /// Session was cleared (terminal grant error).
2359        Terminal,
2360        /// Session was preserved; error message contains this fragment.
2361        Preserved(&'static str),
2362    }
2363
2364    fn run_classify_matrix(status: reqwest::StatusCode, body: &str, expected: ClassifyOutcome) {
2365        let _guard = TestAuthDirGuard::new();
2366        let session = sample_session();
2367        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2368
2369        let err = classify_refresh_status_error(status, body);
2370        let msg = err.to_string();
2371        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("load");
2372
2373        match expected {
2374            ClassifyOutcome::Terminal => {
2375                assert!(msg.contains("session expired"), "terminal error should say 'session expired': {msg}");
2376                assert!(loaded.is_none(), "session should be cleared for terminal: {status} {body}");
2377            }
2378            ClassifyOutcome::Preserved(frag) => {
2379                assert!(!msg.contains("session expired"), "non-terminal should not say 'session expired': {msg}");
2380                assert!(loaded.is_some(), "session should be preserved for: {status} {body}");
2381                if !frag.is_empty() {
2382                    assert!(msg.contains(frag), "error should contain '{frag}': {msg}");
2383                }
2384            }
2385        }
2386    }
2387
2388    #[test]
2389    fn classify_matrix_terminal_400_flat() {
2390        run_classify_matrix(
2391            reqwest::StatusCode::BAD_REQUEST,
2392            r#"{"error": "invalid_grant"}"#,
2393            ClassifyOutcome::Terminal,
2394        );
2395    }
2396
2397    #[test]
2398    fn classify_matrix_terminal_400_nested_code() {
2399        run_classify_matrix(
2400            reqwest::StatusCode::BAD_REQUEST,
2401            r#"{"error": {"code": "invalid_grant", "message": "..."}}"#,
2402            ClassifyOutcome::Terminal,
2403        );
2404    }
2405
2406    #[test]
2407    fn classify_matrix_terminal_400_nested_type() {
2408        run_classify_matrix(
2409            reqwest::StatusCode::BAD_REQUEST,
2410            r#"{"error": {"type": "invalid_grant", "message": "..."}}"#,
2411            ClassifyOutcome::Terminal,
2412        );
2413    }
2414
2415    #[test]
2416    fn classify_matrix_terminal_400_toplevel_code() {
2417        run_classify_matrix(
2418            reqwest::StatusCode::BAD_REQUEST,
2419            r#"{"code": "refresh_token_revoked", "message": "..."}"#,
2420            ClassifyOutcome::Terminal,
2421        );
2422    }
2423
2424    #[test]
2425    fn classify_matrix_terminal_401_flat() {
2426        // Terminal codes on 401 should also clear the session.
2427        run_classify_matrix(
2428            reqwest::StatusCode::UNAUTHORIZED,
2429            r#"{"error": "invalid_token"}"#,
2430            ClassifyOutcome::Terminal,
2431        );
2432    }
2433
2434    #[test]
2435    fn classify_matrix_terminal_401_nested() {
2436        run_classify_matrix(
2437            reqwest::StatusCode::UNAUTHORIZED,
2438            r#"{"error": {"code": "refresh_token_expired"}}"#,
2439            ClassifyOutcome::Terminal,
2440        );
2441    }
2442
2443    #[test]
2444    fn classify_matrix_terminal_401_refresh_token_invalidated() {
2445        run_classify_matrix(
2446            reqwest::StatusCode::UNAUTHORIZED,
2447            r#"{"error": "refresh_token_invalidated"}"#,
2448            ClassifyOutcome::Terminal,
2449        );
2450    }
2451
2452    #[test]
2453    fn classify_matrix_terminal_400_toplevel_refresh_token_reused() {
2454        run_classify_matrix(
2455            reqwest::StatusCode::BAD_REQUEST,
2456            r#"{"code": "refresh_token_reused", "message": "..."}"#,
2457            ClassifyOutcome::Terminal,
2458        );
2459    }
2460
2461    #[test]
2462    fn classify_matrix_message_only_does_not_clear_session() {
2463        // A body with only a free-text "message" field (no structured code/type)
2464        // must NOT be treated as terminal, even if the message text happens to
2465        // contain a terminal code string. This prevents false-positive session
2466        // clearing from descriptive error messages.
2467        run_classify_matrix(
2468            reqwest::StatusCode::BAD_REQUEST,
2469            r#"{"error": {"message": "invalid_grant"}}"#,
2470            ClassifyOutcome::Preserved("HTTP 400"),
2471        );
2472    }
2473
2474    #[test]
2475    fn classify_matrix_invalid_client_preserves() {
2476        run_classify_matrix(
2477            reqwest::StatusCode::BAD_REQUEST,
2478            r#"{"error": "invalid_client"}"#,
2479            ClassifyOutcome::Preserved("invalid_client"),
2480        );
2481    }
2482
2483    #[test]
2484    fn classify_matrix_invalid_client_401_preserves() {
2485        run_classify_matrix(
2486            reqwest::StatusCode::UNAUTHORIZED,
2487            r#"{"error": "invalid_client"}"#,
2488            ClassifyOutcome::Preserved("invalid_client"),
2489        );
2490    }
2491
2492    #[test]
2493    fn classify_matrix_429_throttling_preserves() {
2494        run_classify_matrix(
2495            reqwest::StatusCode::TOO_MANY_REQUESTS,
2496            r#"{"error": "rate_limited"}"#,
2497            ClassifyOutcome::Preserved("rate-limited"),
2498        );
2499    }
2500
2501    #[test]
2502    fn classify_matrix_500_server_error_preserves() {
2503        run_classify_matrix(
2504            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2505            r#"{"error": "internal_error"}"#,
2506            ClassifyOutcome::Preserved("HTTP 500"),
2507        );
2508    }
2509
2510    #[test]
2511    fn classify_matrix_502_bad_gateway_preserves() {
2512        run_classify_matrix(reqwest::StatusCode::BAD_GATEWAY, "", ClassifyOutcome::Preserved("HTTP 502"));
2513    }
2514
2515    #[test]
2516    fn classify_matrix_503_service_unavailable_preserves() {
2517        run_classify_matrix(reqwest::StatusCode::SERVICE_UNAVAILABLE, "", ClassifyOutcome::Preserved("HTTP 503"));
2518    }
2519
2520    #[test]
2521    fn classify_matrix_ambiguous_401_empty_body_preserves() {
2522        run_classify_matrix(reqwest::StatusCode::UNAUTHORIZED, "", ClassifyOutcome::Preserved("HTTP 401"));
2523    }
2524
2525    #[test]
2526    fn classify_matrix_400_nonterminal_code_preserves() {
2527        // A 400 with a code that is NOT in the terminal list should preserve.
2528        run_classify_matrix(
2529            reqwest::StatusCode::BAD_REQUEST,
2530            r#"{"error": "some_unknown_error"}"#,
2531            ClassifyOutcome::Preserved("HTTP 400"),
2532        );
2533    }
2534
2535    #[test]
2536    fn classify_matrix_never_leaks_body_in_any_branch() {
2537        // Terminal branch
2538        let _guard = TestAuthDirGuard::new();
2539        let err = classify_refresh_status_error(
2540            reqwest::StatusCode::BAD_REQUEST,
2541            r#"{"error": "invalid_grant", "leak": "TERMINAL_LEAK"}"#,
2542        );
2543        assert!(!err.to_string().contains("TERMINAL_LEAK"));
2544
2545        // Preserved branch (invalid_client)
2546        let err = classify_refresh_status_error(
2547            reqwest::StatusCode::BAD_REQUEST,
2548            r#"{"error": "invalid_client", "leak": "CLIENT_LEAK"}"#,
2549        );
2550        assert!(!err.to_string().contains("CLIENT_LEAK"));
2551
2552        // Preserved branch (429)
2553        let err = classify_refresh_status_error(
2554            reqwest::StatusCode::TOO_MANY_REQUESTS,
2555            r#"{"error": "rate_limited", "leak": "THROTTLE_LEAK"}"#,
2556        );
2557        assert!(!err.to_string().contains("THROTTLE_LEAK"));
2558    }
2559
2560    // ── Error code extraction shape tests ──
2561
2562    #[test]
2563    fn extract_error_code_nested_with_type_field() {
2564        let body = r#"{"error": {"type": "invalid_grant", "message": "..."}}"#;
2565        assert_eq!(extract_error_code(body), "invalid_grant");
2566    }
2567
2568    #[test]
2569    fn extract_error_code_toplevel_code_field() {
2570        let body = r#"{"code": "refresh_token_expired", "message": "..."}"#;
2571        assert_eq!(extract_error_code(body), "refresh_token_expired");
2572    }
2573
2574    #[test]
2575    fn extract_error_code_case_insensitive_normalization() {
2576        assert_eq!(extract_error_code(r#"{"error": "INVALID_GRANT"}"#), "invalid_grant");
2577    }
2578
2579    #[test]
2580    fn extract_error_code_no_substring_matching() {
2581        // "invalid_grant_really" is not a terminal code — extract_error_code
2582        // returns it verbatim, but classify won't match it as terminal.
2583        let code = extract_error_code(r#"{"error": "invalid_grant_really_not_a_real_code"}"#);
2584        assert_eq!(code, "invalid_grant_really_not_a_real_code");
2585        // Verify classify does NOT treat this as terminal.
2586        let _guard = TestAuthDirGuard::new();
2587        let session = sample_session();
2588        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2589        let err = classify_refresh_status_error(
2590            reqwest::StatusCode::BAD_REQUEST,
2591            r#"{"error": "invalid_grant_really_not_a_real_code"}"#,
2592        );
2593        assert!(!err.to_string().contains("session expired"));
2594        assert!(
2595            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2596                .expect("load")
2597                .is_some()
2598        );
2599    }
2600
2601    // ── PKCE and callback Debug redaction tests ──
2602
2603    #[test]
2604    fn pkce_challenge_debug_redacts_verifier() {
2605        let challenge = generate_pkce_challenge().expect("generate pkce");
2606        let debug_str = format!("{challenge:?}");
2607        assert!(!debug_str.contains(&challenge.code_verifier), "code_verifier leaked: {debug_str}");
2608        assert!(debug_str.contains("<redacted>"), "verifier should be redacted: {debug_str}");
2609        // Challenge and method are safe to display.
2610        assert!(debug_str.contains(&challenge.code_challenge), "code_challenge should be visible: {debug_str}");
2611    }
2612
2613    #[test]
2614    fn auth_callback_outcome_debug_redacts_code() {
2615        let outcome = AuthCallbackOutcome::Code("super-secret-auth-code".to_string());
2616        let debug_str = format!("{outcome:?}");
2617        assert!(!debug_str.contains("super-secret-auth-code"), "authorization code leaked: {debug_str}");
2618        assert!(debug_str.contains("<redacted>"), "code should be redacted: {debug_str}");
2619    }
2620
2621    #[test]
2622    fn auth_callback_outcome_debug_shows_cancelled_and_redacts_error() {
2623        let cancelled = format!("{:?}", AuthCallbackOutcome::Cancelled);
2624        assert!(cancelled.contains("Cancelled"));
2625
2626        // Error messages from OAuth callbacks are untrusted query parameters
2627        // that may contain sensitive values — Debug must redact them.
2628        let error = format!("{:?}", AuthCallbackOutcome::Error("access_denied".to_string()));
2629        assert!(!error.contains("access_denied"), "error message leaked through Debug: {error}");
2630        assert!(error.contains("<redacted>"), "error should be redacted: {error}");
2631    }
2632}