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        let _ = 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[..remaining]);
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.as_ref()[..32]
1371        .try_into()
1372        .context("openai session encryption key was too short")?;
1373    let unbound =
1374        UnboundKey::new(&aead::AES_256_GCM, key_bytes).map_err(|_| anyhow!("invalid openai session encryption key"))?;
1375    Ok(LessSafeKey::new(unbound))
1376}
1377
1378fn now_secs() -> u64 {
1379    std::time::SystemTime::now()
1380        .duration_since(std::time::UNIX_EPOCH)
1381        .map(|duration| duration.as_secs())
1382        .unwrap_or(0)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use crate::AuthCallbackOutcome;
1389    use crate::generate_pkce_challenge;
1390    use assert_fs::TempDir;
1391    use serial_test::serial;
1392    use std::sync::Arc;
1393
1394    struct ExternalRefresher;
1395
1396    #[async_trait]
1397    impl OpenAIChatGptSessionRefresher for ExternalRefresher {
1398        async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1399            let mut refreshed = current.clone();
1400            refreshed.access_token = "oauth-access-refreshed".to_string();
1401            refreshed.refreshed_at = current.refreshed_at.saturating_add(1);
1402            refreshed.expires_at = Some(now_secs() + 3600);
1403            Ok(refreshed)
1404        }
1405    }
1406
1407    struct TestAuthDirGuard {
1408        temp_dir: Option<TempDir>,
1409        codex_temp_dir: Option<TempDir>,
1410        previous: Option<PathBuf>,
1411        previous_codex_home: Option<String>,
1412    }
1413
1414    impl TestAuthDirGuard {
1415        fn new() -> Self {
1416            let temp_dir = TempDir::new().expect("create temp auth dir");
1417            let previous = crate::storage_paths::auth_storage_dir_override_for_tests().expect("read auth dir override");
1418            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
1419                .expect("set temp auth dir override");
1420
1421            // Isolate CODEX_HOME so the Codex auth.json fallback doesn't pick
1422            // up a real Codex session from the user's machine during tests.
1423            let codex_temp_dir = TempDir::new().expect("create temp codex home");
1424            let previous_codex_home = std::env::var("CODEX_HOME").ok();
1425            vtcode_commons::env_lock::set_var("CODEX_HOME", codex_temp_dir.path());
1426
1427            Self {
1428                temp_dir: Some(temp_dir),
1429                codex_temp_dir: Some(codex_temp_dir),
1430                previous,
1431                previous_codex_home,
1432            }
1433        }
1434    }
1435
1436    impl Drop for TestAuthDirGuard {
1437        fn drop(&mut self) {
1438            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1439                .expect("restore auth dir override");
1440            if let Some(temp_dir) = self.temp_dir.take() {
1441                temp_dir.close().expect("remove temp auth dir");
1442            }
1443            vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", self.previous_codex_home.as_deref());
1444            if let Some(codex_temp_dir) = self.codex_temp_dir.take() {
1445                codex_temp_dir.close().expect("remove temp codex home");
1446            }
1447        }
1448    }
1449
1450    fn sample_session() -> OpenAIChatGptSession {
1451        OpenAIChatGptSession {
1452            openai_api_key: "api-key".to_string(),
1453            id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1454            access_token: "oauth-access".to_string(),
1455            refresh_token: "refresh-token".to_string(),
1456            account_id: Some("acc_123".to_string()),
1457            email: Some("test@example.com".to_string()),
1458            plan: Some("plus".to_string()),
1459            obtained_at: 10,
1460            refreshed_at: 10,
1461            expires_at: Some(now_secs() + 3600),
1462        }
1463    }
1464
1465    #[test]
1466    fn auth_url_contains_expected_openai_parameters() {
1467        // RAII guard locks env and restores both vars on drop (panic-safe).
1468        let env = OauthEnvGuard::new();
1469        env.remove_client_id();
1470        env.remove_originator();
1471
1472        let challenge = PkceChallenge {
1473            code_verifier: "verifier".to_string(),
1474            code_challenge: "challenge".to_string(),
1475            code_challenge_method: "S256".to_string(),
1476        };
1477
1478        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1479        assert!(url.starts_with(OPENAI_AUTH_URL));
1480        assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1481        assert!(url.contains("code_challenge=challenge"));
1482        assert!(url.contains("codex_cli_simplified_flow=true"));
1483        assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1484        assert!(url.contains("state=test-state"));
1485    }
1486
1487    #[test]
1488    fn auth_url_honors_custom_client_id_env_override() {
1489        // RAII guard locks env and restores both vars on drop (panic-safe).
1490        let env = OauthEnvGuard::new();
1491        env.set_client_id("app_custom_override");
1492        env.set_originator("vtcode_custom");
1493
1494        let challenge = PkceChallenge {
1495            code_verifier: "verifier".to_string(),
1496            code_challenge: "challenge".to_string(),
1497            code_challenge_method: "S256".to_string(),
1498        };
1499        let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state").expect("auth url");
1500        assert!(url.contains("client_id=app_custom_override"), "custom client_id not used: {url}");
1501        assert!(url.contains("originator=vtcode_custom"), "custom originator not used: {url}");
1502        assert!(!url.contains("app_EMoamEEZ73f0CkXaXp7hrann"), "default client_id leaked through override: {url}");
1503    }
1504
1505    /// RAII guard that locks the environment and restores both OAuth identity
1506    /// env vars on drop — even if the test panics. This ensures parallel
1507    /// tests don't leak env mutations to each other.
1508    struct OauthEnvGuard {
1509        env: vtcode_commons::env_lock::EnvGuard,
1510        prev_client_id: Option<std::ffi::OsString>,
1511        prev_originator: Option<std::ffi::OsString>,
1512    }
1513
1514    impl OauthEnvGuard {
1515        fn new() -> Self {
1516            let env = vtcode_commons::env_lock::lock();
1517            let prev_client_id = std::env::var_os("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1518            let prev_originator = std::env::var_os("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1519            Self { env, prev_client_id, prev_originator }
1520        }
1521
1522        fn set_client_id(&self, value: &str) {
1523            self.env.set_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", value);
1524        }
1525
1526        fn set_originator(&self, value: &str) {
1527            self.env.set_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", value);
1528        }
1529
1530        fn remove_client_id(&self) {
1531            self.env.remove_var("VTCODE_OPENAI_OAUTH_CLIENT_ID");
1532        }
1533
1534        fn remove_originator(&self) {
1535            self.env.remove_var("VTCODE_OPENAI_OAUTH_ORIGINATOR");
1536        }
1537    }
1538
1539    impl Drop for OauthEnvGuard {
1540        fn drop(&mut self) {
1541            self.env
1542                .restore_var("VTCODE_OPENAI_OAUTH_CLIENT_ID", self.prev_client_id.take());
1543            self.env
1544                .restore_var("VTCODE_OPENAI_OAUTH_ORIGINATOR", self.prev_originator.take());
1545        }
1546    }
1547
1548    #[test]
1549    fn resolve_oauth_client_identity_both_defaults() {
1550        let env = OauthEnvGuard::new();
1551        env.remove_client_id();
1552        env.remove_originator();
1553
1554        let identity = resolve_oauth_client_identity().expect("defaults");
1555        assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1556        assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1557    }
1558
1559    #[test]
1560    fn resolve_oauth_client_identity_both_custom() {
1561        let env = OauthEnvGuard::new();
1562        env.set_client_id("app_custom");
1563        env.set_originator("my_originator");
1564
1565        let identity = resolve_oauth_client_identity().expect("custom pair");
1566        assert_eq!(identity.client_id, "app_custom");
1567        assert_eq!(identity.originator, "my_originator");
1568    }
1569
1570    #[test]
1571    fn resolve_oauth_client_identity_only_client_id_is_error() {
1572        let env = OauthEnvGuard::new();
1573        env.set_client_id("app_custom");
1574        env.remove_originator();
1575
1576        let err = resolve_oauth_client_identity().unwrap_err();
1577        let msg = err.to_string();
1578        assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the set var: {msg}");
1579        assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the missing var: {msg}");
1580    }
1581
1582    #[test]
1583    fn resolve_oauth_client_identity_only_originator_is_error() {
1584        let env = OauthEnvGuard::new();
1585        env.remove_client_id();
1586        env.set_originator("my_originator");
1587
1588        let err = resolve_oauth_client_identity().unwrap_err();
1589        let msg = err.to_string();
1590        assert!(msg.contains("VTCODE_OPENAI_OAUTH_ORIGINATOR"), "error should name the set var: {msg}");
1591        assert!(msg.contains("VTCODE_OPENAI_OAUTH_CLIENT_ID"), "error should name the missing var: {msg}");
1592    }
1593
1594    #[test]
1595    fn resolve_oauth_client_identity_blank_values_treated_as_unset() {
1596        let env = OauthEnvGuard::new();
1597        env.set_client_id("   ");
1598        env.set_originator("  ");
1599
1600        // Blank values are treated as unset → defaults used.
1601        let identity = resolve_oauth_client_identity().expect("defaults from blank");
1602        assert_eq!(identity.client_id, DEFAULT_OPENAI_CLIENT_ID);
1603        assert_eq!(identity.originator, DEFAULT_OPENAI_ORIGINATOR);
1604    }
1605
1606    #[test]
1607    fn parse_jwt_claims_extracts_openai_claims() {
1608        let claims = parse_jwt_claims(
1609            "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1610        )
1611        .expect("claims");
1612        assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1613        assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1614        assert_eq!(claims.plan.as_deref(), Some("plus"));
1615    }
1616
1617    #[test]
1618    fn session_refresh_due_uses_expiry_and_age() {
1619        let mut session = sample_session();
1620        let now = now_secs();
1621        session.obtained_at = now;
1622        session.refreshed_at = now;
1623        session.expires_at = Some(now + 3600);
1624        assert!(!session.is_refresh_due());
1625        session.expires_at = Some(now);
1626        assert!(session.is_refresh_due());
1627    }
1628
1629    #[tokio::test]
1630    #[serial]
1631    async fn external_auth_handle_refreshes_without_persisting_session() {
1632        let _guard = TestAuthDirGuard::new();
1633        let mut session = sample_session();
1634        session.openai_api_key.clear();
1635        session.expires_at = Some(now_secs().saturating_sub(1));
1636        let handle = OpenAIChatGptAuthHandle::new_external(session, true, Arc::new(ExternalRefresher));
1637
1638        assert!(handle.using_external_tokens());
1639        assert!(
1640            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1641                .expect("load session")
1642                .is_none()
1643        );
1644
1645        handle.force_refresh().await.expect("force refresh");
1646
1647        assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1648        assert!(
1649            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1650                .expect("load session")
1651                .is_none()
1652        );
1653    }
1654
1655    struct CountingExternalRefresher {
1656        calls: Arc<Mutex<usize>>,
1657    }
1658
1659    #[async_trait]
1660    impl OpenAIChatGptSessionRefresher for CountingExternalRefresher {
1661        async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
1662            let mut calls = self.calls.lock().expect("refresh calls mutex should lock");
1663            *calls += 1;
1664            drop(calls);
1665
1666            let mut refreshed = current.clone();
1667            refreshed.access_token = "oauth-access-refreshed".to_string();
1668            refreshed.refreshed_at = now_secs();
1669            refreshed.expires_at = Some(now_secs() + 3600);
1670            Ok(refreshed)
1671        }
1672    }
1673
1674    #[tokio::test]
1675    async fn refresh_if_needed_serializes_external_refreshes() {
1676        let mut session = sample_session();
1677        session.openai_api_key.clear();
1678        session.expires_at = Some(now_secs().saturating_sub(1));
1679        let calls = Arc::new(Mutex::new(0usize));
1680        let handle = OpenAIChatGptAuthHandle::new_external(
1681            session,
1682            true,
1683            Arc::new(CountingExternalRefresher { calls: Arc::clone(&calls) }),
1684        );
1685
1686        let first = handle.clone();
1687        let second = handle.clone();
1688        let (first_result, second_result) = tokio::join!(first.refresh_if_needed(), second.refresh_if_needed());
1689
1690        first_result.expect("first refresh should succeed");
1691        second_result.expect("second refresh should succeed");
1692        assert_eq!(
1693            *calls.lock().expect("refresh calls mutex should lock"),
1694            1,
1695            "concurrent refresh_if_needed calls should share one refresh"
1696        );
1697        assert_eq!(handle.current_api_key().expect("current api key"), "oauth-access-refreshed");
1698    }
1699
1700    #[test]
1701    #[serial]
1702    fn resolve_openai_auth_prefers_chatgpt_in_auto_permission() {
1703        let _guard = TestAuthDirGuard::new();
1704        let session = sample_session();
1705        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1706        let resolved = resolve_openai_auth(
1707            &OpenAIAuthConfig::default(),
1708            AuthCredentialsStoreMode::File,
1709            Some("api-key".to_string()),
1710        )
1711        .expect("resolved auth");
1712        assert!(resolved.using_chatgpt());
1713        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1714    }
1715
1716    #[test]
1717    #[serial]
1718    #[cfg(unix)]
1719    fn file_storage_uses_private_permissions() {
1720        use std::fs;
1721        use std::os::unix::fs::PermissionsExt;
1722
1723        let _guard = TestAuthDirGuard::new();
1724        let session = sample_session();
1725
1726        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1727
1728        let metadata = fs::metadata(get_session_path().expect("session path")).expect("read session metadata");
1729        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1730    }
1731
1732    #[test]
1733    #[serial]
1734    fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1735        let _guard = TestAuthDirGuard::new();
1736        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1737        let resolved = resolve_openai_auth(
1738            &OpenAIAuthConfig::default(),
1739            AuthCredentialsStoreMode::File,
1740            Some("api-key".to_string()),
1741        )
1742        .expect("resolved auth");
1743        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1744    }
1745
1746    #[test]
1747    #[serial]
1748    fn resolve_openai_auth_auto_rejects_blank_api_key_without_session() {
1749        let _guard = TestAuthDirGuard::new();
1750        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1751        let error =
1752            resolve_openai_auth(&OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File, Some("   ".to_string()))
1753                .expect_err("blank api key should fail");
1754        assert!(error.to_string().contains("OpenAI API key not found"));
1755    }
1756
1757    #[test]
1758    #[serial]
1759    fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1760        let _guard = TestAuthDirGuard::new();
1761        let session = sample_session();
1762        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1763        let resolved = resolve_openai_auth(
1764            &OpenAIAuthConfig {
1765                preferred_method: OpenAIPreferredMethod::ApiKey,
1766                ..OpenAIAuthConfig::default()
1767            },
1768            AuthCredentialsStoreMode::File,
1769            Some("api-key".to_string()),
1770        )
1771        .expect("resolved auth");
1772        assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1773        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1774    }
1775
1776    #[test]
1777    #[serial]
1778    fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1779        let _guard = TestAuthDirGuard::new();
1780        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1781        let error = resolve_openai_auth(
1782            &OpenAIAuthConfig {
1783                preferred_method: OpenAIPreferredMethod::Chatgpt,
1784                ..OpenAIAuthConfig::default()
1785            },
1786            AuthCredentialsStoreMode::File,
1787            Some("api-key".to_string()),
1788        )
1789        .expect_err("chatgpt mode should require a stored session");
1790        assert!(error.to_string().contains("vtcode login openai"));
1791    }
1792
1793    #[test]
1794    #[serial]
1795    fn summarize_openai_credentials_reports_dual_source_notice() {
1796        let _guard = TestAuthDirGuard::new();
1797        let session = sample_session();
1798        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1799        let overview = summarize_openai_credentials(
1800            &OpenAIAuthConfig::default(),
1801            AuthCredentialsStoreMode::File,
1802            Some("api-key".to_string()),
1803        )
1804        .expect("overview");
1805        assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ChatGpt));
1806        assert!(overview.notice.is_some());
1807        assert!(overview.recommendation.is_some());
1808        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1809    }
1810
1811    #[test]
1812    #[serial]
1813    fn summarize_openai_credentials_respects_api_key_preference() {
1814        let _guard = TestAuthDirGuard::new();
1815        let session = sample_session();
1816        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1817        let overview = summarize_openai_credentials(
1818            &OpenAIAuthConfig {
1819                preferred_method: OpenAIPreferredMethod::ApiKey,
1820                ..OpenAIAuthConfig::default()
1821            },
1822            AuthCredentialsStoreMode::File,
1823            Some("api-key".to_string()),
1824        )
1825        .expect("overview");
1826        assert_eq!(overview.active_source, Some(OpenAIResolvedAuthSource::ApiKey));
1827        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1828    }
1829
1830    #[test]
1831    fn encrypted_file_round_trip_restores_session() {
1832        let session = sample_session();
1833        let encrypted = encrypt_session(&session).expect("encrypt");
1834        let decrypted = decrypt_session(&encrypted).expect("decrypt");
1835        assert_eq!(decrypted.account_id, session.account_id);
1836        assert_eq!(decrypted.email, session.email);
1837        assert_eq!(decrypted.plan, session.plan);
1838    }
1839
1840    #[test]
1841    #[serial]
1842    fn default_loader_falls_back_to_file_session() {
1843        let _guard = TestAuthDirGuard::new();
1844        let session = sample_session();
1845        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1846
1847        let loaded = load_openai_chatgpt_session()
1848            .expect("load session")
1849            .expect("stored session should be found");
1850
1851        assert_eq!(loaded.account_id, session.account_id);
1852        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1853    }
1854
1855    #[test]
1856    #[serial]
1857    fn keyring_mode_loader_falls_back_to_file_session() {
1858        let _guard = TestAuthDirGuard::new();
1859        let session = sample_session();
1860        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save session");
1861
1862        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1863            .expect("load session")
1864            .expect("stored session should be found");
1865
1866        assert_eq!(loaded.email, session.email);
1867        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear session");
1868    }
1869
1870    #[test]
1871    #[serial]
1872    fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1873        let _guard = TestAuthDirGuard::new();
1874        let session = sample_session();
1875        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save file session");
1876
1877        if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring).is_err() {
1878            clear_openai_chatgpt_session().expect("clear session");
1879            assert!(
1880                load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1881                    .expect("load file session")
1882                    .is_none()
1883            );
1884            return;
1885        }
1886
1887        clear_openai_chatgpt_session().expect("clear session");
1888        assert!(
1889            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1890                .expect("load file session")
1891                .is_none()
1892        );
1893        assert!(
1894            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1895                .expect("load keyring session")
1896                .is_none()
1897        );
1898    }
1899
1900    #[test]
1901    fn active_api_bearer_token_falls_back_to_access_token() {
1902        let mut session = sample_session();
1903        session.openai_api_key.clear();
1904
1905        assert_eq!(active_api_bearer_token(&session), "oauth-access");
1906    }
1907
1908    #[test]
1909    fn parse_manual_callback_input_accepts_full_redirect_url() {
1910        let code = parse_openai_chatgpt_manual_callback_input(
1911            "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1912            "test-state",
1913        )
1914        .expect("manual input should parse");
1915        assert_eq!(code, "auth-code");
1916    }
1917
1918    #[test]
1919    fn parse_manual_callback_input_accepts_query_string() {
1920        let code = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=test-state", "test-state")
1921            .expect("manual input should parse");
1922        assert_eq!(code, "auth-code");
1923    }
1924
1925    #[test]
1926    fn parse_manual_callback_input_rejects_bare_code() {
1927        let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1928            .expect_err("bare code should be rejected");
1929        assert!(error.to_string().contains("full redirect url or query string"));
1930    }
1931
1932    #[test]
1933    fn parse_manual_callback_input_rejects_state_mismatch() {
1934        let error = parse_openai_chatgpt_manual_callback_input("code=auth-code&state=wrong-state", "test-state")
1935            .expect_err("state mismatch should fail");
1936        assert!(error.to_string().contains("state mismatch"));
1937    }
1938
1939    #[tokio::test]
1940    #[serial]
1941    async fn refresh_lock_serializes_parallel_acquisition() {
1942        let _guard = TestAuthDirGuard::new();
1943        let first = tokio::spawn(async {
1944            let _lock = acquire_refresh_lock().await.expect("first lock");
1945            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1946        });
1947        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1948
1949        let start = std::time::Instant::now();
1950        let second = tokio::spawn(async {
1951            let _lock = acquire_refresh_lock().await.expect("second lock");
1952        });
1953
1954        first.await.expect("first task");
1955        second.await.expect("second task");
1956        assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1957    }
1958
1959    // ── Debug redaction tests ──
1960
1961    #[test]
1962    fn debug_impl_redacts_all_token_fields() {
1963        let session = sample_session();
1964        let debug_str = format!("{session:?}");
1965        // None of the secret values may appear in the Debug output.
1966        assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
1967        assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
1968        assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
1969        // The id_token JWT body is long; check a distinctive substring.
1970        assert!(!debug_str.contains("eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20i"), "id_token leaked: {debug_str}");
1971        // Non-secret metadata should still be present.
1972        assert!(debug_str.contains("test@example.com"), "email should be visible: {debug_str}");
1973        assert!(debug_str.contains("plus"), "plan should be visible: {debug_str}");
1974    }
1975
1976    #[test]
1977    fn debug_impl_redacts_resolved_auth_api_key() {
1978        let resolved = OpenAIResolvedAuth::ApiKey { api_key: "sk-secret-key".to_string() };
1979        let debug_str = format!("{resolved:?}");
1980        assert!(!debug_str.contains("sk-secret-key"), "api_key leaked: {debug_str}");
1981    }
1982
1983    #[test]
1984    fn debug_impl_redacts_resolved_auth_chatgpt_handle() {
1985        let _guard = TestAuthDirGuard::new();
1986        let session = sample_session();
1987        let handle = OpenAIChatGptAuthHandle::new(session, OpenAIAuthConfig::default(), AuthCredentialsStoreMode::File);
1988        let resolved = OpenAIResolvedAuth::ChatGpt { api_key: "sk-secret-bearer".to_string(), handle };
1989        let debug_str = format!("{resolved:?}");
1990        assert!(!debug_str.contains("sk-secret-bearer"), "bearer leaked: {debug_str}");
1991    }
1992
1993    #[test]
1994    fn credential_overview_carries_no_token_fields() {
1995        let _guard = TestAuthDirGuard::new();
1996        let session = sample_session();
1997        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
1998        let overview = summarize_openai_credentials(
1999            &OpenAIAuthConfig::default(),
2000            AuthCredentialsStoreMode::File,
2001            Some("sk-overview-key".to_string()),
2002        )
2003        .expect("overview");
2004        // The overview is a display struct — verify it only carries metadata,
2005        // not the raw session or token strings.
2006        let debug_str = format!("{overview:?}");
2007        assert!(!debug_str.contains("oauth-access"), "access_token leaked: {debug_str}");
2008        assert!(!debug_str.contains("refresh-token"), "refresh_token leaked: {debug_str}");
2009        assert!(!debug_str.contains("api-key"), "openai_api_key leaked: {debug_str}");
2010        // The overview should not contain the raw API key value either.
2011        assert!(!debug_str.contains("sk-overview-key"), "api key value leaked: {debug_str}");
2012        clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("clear");
2013    }
2014
2015    // ── Partial refresh response merge tests ──
2016    //
2017    // These test merge_refresh_response directly with resp.id_token = None,
2018    // which skips the HTTP API-key exchange (has_new_id_token = false).
2019    // This lets us verify field-preservation behavior without network access.
2020
2021    #[tokio::test]
2022    async fn merge_preserves_omitted_access_token() {
2023        let current = sample_session();
2024        let resp = OpenAIRefreshResponse {
2025            id_token: None,
2026            access_token: None,
2027            refresh_token: Some("new-refresh".to_string()),
2028            expires_in: Some(3600),
2029        };
2030        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2031        assert_eq!(merged.access_token, current.access_token, "omitted access_token should be preserved");
2032        assert_eq!(merged.refresh_token, "new-refresh");
2033    }
2034
2035    #[tokio::test]
2036    async fn merge_preserves_omitted_refresh_token() {
2037        let current = sample_session();
2038        let resp = OpenAIRefreshResponse {
2039            id_token: None,
2040            access_token: Some("new-access".to_string()),
2041            refresh_token: None,
2042            expires_in: None,
2043        };
2044        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2045        assert_eq!(merged.refresh_token, current.refresh_token, "omitted refresh_token should be preserved");
2046        assert_eq!(merged.access_token, "new-access");
2047    }
2048
2049    #[tokio::test]
2050    async fn merge_preserves_omitted_id_token_and_api_key() {
2051        let current = sample_session();
2052        let resp = OpenAIRefreshResponse {
2053            id_token: None,
2054            access_token: Some("new-access".to_string()),
2055            refresh_token: None,
2056            expires_in: Some(1800),
2057        };
2058        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2059        // No new id_token → old id_token and api_key preserved, no HTTP exchange.
2060        assert_eq!(merged.id_token, current.id_token, "omitted id_token should be preserved");
2061        assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key should be preserved without new id_token");
2062        // Email/plan/account_id also preserved without a new id_token.
2063        assert_eq!(merged.email, current.email);
2064        assert_eq!(merged.plan, current.plan);
2065    }
2066
2067    #[tokio::test]
2068    async fn merge_all_omitted_preserves_everything() {
2069        let current = sample_session();
2070        let resp = OpenAIRefreshResponse {
2071            id_token: None,
2072            access_token: None,
2073            refresh_token: None,
2074            expires_in: None,
2075        };
2076        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2077        assert_eq!(merged.id_token, current.id_token);
2078        assert_eq!(merged.access_token, current.access_token);
2079        assert_eq!(merged.refresh_token, current.refresh_token);
2080        assert_eq!(merged.openai_api_key, current.openai_api_key);
2081        assert_eq!(merged.email, current.email);
2082        // expires_in is None and no new access_token → old expiry preserved.
2083        assert_eq!(merged.expires_at, current.expires_at);
2084    }
2085
2086    #[tokio::test]
2087    async fn merge_new_access_token_updates_bearer_without_id_token() {
2088        let current = sample_session();
2089        let resp = OpenAIRefreshResponse {
2090            id_token: None,
2091            access_token: Some("replaced-access".to_string()),
2092            refresh_token: None,
2093            expires_in: Some(7200),
2094        };
2095        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2096        assert_eq!(merged.access_token, "replaced-access");
2097        // active_api_bearer_token should use the exchanged api_key (unchanged)
2098        // because no new id_token was provided.
2099        assert_eq!(active_api_bearer_token(&merged), current.openai_api_key);
2100    }
2101
2102    // ── Blank-string refresh field tests ──
2103    //
2104    // Some token endpoints return empty strings for omitted fields rather than
2105    // leaving them out. These verify that blank strings are treated as omitted.
2106
2107    #[tokio::test]
2108    async fn merge_treats_blank_access_token_as_omitted() {
2109        let current = sample_session();
2110        let resp = OpenAIRefreshResponse {
2111            id_token: None,
2112            access_token: Some("   ".to_string()),
2113            refresh_token: Some("new-refresh".to_string()),
2114            expires_in: Some(3600),
2115        };
2116        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2117        assert_eq!(merged.access_token, current.access_token, "blank access_token should be treated as omitted");
2118        assert_eq!(merged.refresh_token, "new-refresh");
2119    }
2120
2121    #[tokio::test]
2122    async fn merge_treats_blank_refresh_token_as_omitted() {
2123        let current = sample_session();
2124        let resp = OpenAIRefreshResponse {
2125            id_token: None,
2126            access_token: Some("new-access".to_string()),
2127            refresh_token: Some(String::new()),
2128            expires_in: None,
2129        };
2130        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2131        assert_eq!(merged.refresh_token, current.refresh_token, "blank refresh_token should be treated as omitted");
2132        assert_eq!(merged.access_token, "new-access");
2133    }
2134
2135    #[tokio::test]
2136    async fn merge_treats_blank_id_token_as_omitted() {
2137        let current = sample_session();
2138        let resp = OpenAIRefreshResponse {
2139            id_token: Some("  ".to_string()),
2140            access_token: Some("new-access".to_string()),
2141            refresh_token: None,
2142            expires_in: None,
2143        };
2144        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2145        // Blank id_token → treated as omitted → no HTTP exchange, old preserved.
2146        assert_eq!(merged.id_token, current.id_token, "blank id_token should be treated as omitted");
2147        assert_eq!(merged.openai_api_key, current.openai_api_key, "api_key preserved when id_token is blank");
2148    }
2149
2150    // ── Opaque access token expiry tests ──
2151
2152    #[tokio::test]
2153    async fn merge_changed_opaque_access_token_clears_stale_expiry() {
2154        let mut current = sample_session();
2155        current.expires_at = Some(now_secs() + 3600); // old expiry for old token
2156
2157        // New access token without expires_in and without a parseable JWT exp.
2158        // "opaque-new-token" is not a JWT, so parse_jwt_exp returns None.
2159        let resp = OpenAIRefreshResponse {
2160            id_token: None,
2161            access_token: Some("opaque-new-token".to_string()),
2162            refresh_token: None,
2163            expires_in: None,
2164        };
2165        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2166        assert_eq!(merged.access_token, "opaque-new-token");
2167        // Changed token without expires_in or JWT exp → expiry must be None,
2168        // NOT the old token's expiry.
2169        assert_eq!(merged.expires_at, None, "changed opaque access token must not inherit old token's expiry");
2170    }
2171
2172    #[tokio::test]
2173    async fn merge_repeated_opaque_access_token_preserves_expiry() {
2174        let old_expiry = now_secs() + 3600;
2175        let mut current = sample_session();
2176        current.access_token = "opaque-same-token".to_string();
2177        current.expires_at = Some(old_expiry);
2178
2179        // The endpoint repeats the SAME opaque access token without expires_in.
2180        // Since the token didn't change, the old expiry is still valid.
2181        let resp = OpenAIRefreshResponse {
2182            id_token: None,
2183            access_token: Some("opaque-same-token".to_string()),
2184            refresh_token: Some("new-refresh".to_string()),
2185            expires_in: None,
2186        };
2187        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2188        assert_eq!(merged.access_token, "opaque-same-token");
2189        assert_eq!(merged.expires_at, Some(old_expiry), "repeated same opaque access token must preserve old expiry");
2190    }
2191
2192    #[tokio::test]
2193    async fn merge_omitted_access_token_preserves_old_expiry() {
2194        let old_expiry = now_secs() + 3600;
2195        let mut current = sample_session();
2196        current.expires_at = Some(old_expiry);
2197
2198        let resp = OpenAIRefreshResponse {
2199            id_token: None,
2200            access_token: None,
2201            refresh_token: Some("new-refresh".to_string()),
2202            expires_in: None,
2203        };
2204        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2205        // No new access_token and no expires_in → old expiry preserved.
2206        assert_eq!(merged.expires_at, Some(old_expiry), "omitted access_token should preserve old expiry");
2207    }
2208
2209    #[tokio::test]
2210    async fn merge_expires_in_overrides_for_omitted_access_token() {
2211        let old_expiry = now_secs() + 3600;
2212        let mut current = sample_session();
2213        current.expires_at = Some(old_expiry);
2214
2215        let resp = OpenAIRefreshResponse {
2216            id_token: None,
2217            access_token: None,
2218            refresh_token: None,
2219            expires_in: Some(1800),
2220        };
2221        let merged = merge_refresh_response(&current, resp).await.expect("merge");
2222        // expires_in takes priority over old expiry even without a new access_token.
2223        assert_ne!(merged.expires_at, Some(old_expiry));
2224        assert!(merged.expires_at.is_some());
2225    }
2226
2227    // ── Error classification tests (pure, no network) ──
2228
2229    #[test]
2230    fn extract_error_code_flat_form() {
2231        assert_eq!(extract_error_code(r#"{"error": "invalid_grant"}"#), "invalid_grant");
2232    }
2233
2234    #[test]
2235    fn extract_error_code_nested_form() {
2236        let body = r#"{"error": {"code": "refresh_token_expired", "message": "token expired"}}"#;
2237        assert_eq!(extract_error_code(body), "refresh_token_expired");
2238    }
2239
2240    #[test]
2241    fn extract_error_code_nested_form_does_not_fall_back_to_message() {
2242        // A descriptive message that happens to contain a terminal code string
2243        // must NOT be treated as a structured error code. Only `code` and
2244        // `type` fields are recognized — `message` is free-text.
2245        let body = r#"{"error": {"message": "invalid_grant"}}"#;
2246        assert_eq!(extract_error_code(body), "", "message field should not be used as error code: {body}");
2247    }
2248
2249    #[test]
2250    fn extract_error_code_empty_body() {
2251        assert_eq!(extract_error_code(""), "");
2252    }
2253
2254    #[test]
2255    fn extract_error_code_non_json_body() {
2256        assert_eq!(extract_error_code("Internal Server Error"), "");
2257    }
2258
2259    #[test]
2260    fn extract_error_code_blank_error_field() {
2261        assert_eq!(extract_error_code(r#"{"error": "  "}"#), "");
2262    }
2263
2264    #[test]
2265    fn classify_refresh_status_error_invalid_grant_clears_session() {
2266        let _guard = TestAuthDirGuard::new();
2267        // Store a session so we can verify it gets cleared.
2268        let session = sample_session();
2269        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2270        assert!(
2271            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2272                .expect("load")
2273                .is_some()
2274        );
2275
2276        let err = classify_refresh_status_error(reqwest::StatusCode::BAD_REQUEST, r#"{"error": "invalid_grant"}"#);
2277        assert!(err.to_string().contains("session expired"));
2278
2279        // Session should have been cleared.
2280        assert!(
2281            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2282                .expect("load")
2283                .is_none()
2284        );
2285    }
2286
2287    #[test]
2288    fn classify_refresh_status_error_nested_refresh_token_expired_clears_session() {
2289        let _guard = TestAuthDirGuard::new();
2290        let session = sample_session();
2291        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2292
2293        let err = classify_refresh_status_error(
2294            reqwest::StatusCode::BAD_REQUEST,
2295            r#"{"error": {"code": "refresh_token_expired", "message": "The refresh token has expired"}}"#,
2296        );
2297        assert!(err.to_string().contains("session expired"));
2298
2299        assert!(
2300            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2301                .expect("load")
2302                .is_none()
2303        );
2304    }
2305
2306    #[test]
2307    fn classify_refresh_status_error_unauthorized_preserves_session() {
2308        let _guard = TestAuthDirGuard::new();
2309        let session = sample_session();
2310        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2311
2312        let err = classify_refresh_status_error(reqwest::StatusCode::UNAUTHORIZED, "");
2313        assert!(err.to_string().contains("HTTP 401"), "should report HTTP 401: {err}");
2314        // 401 without a confirmed terminal code preserves the session.
2315        assert!(
2316            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2317                .expect("load")
2318                .is_some(),
2319            "session should be preserved on ambiguous 401"
2320        );
2321    }
2322
2323    #[test]
2324    fn classify_refresh_status_error_server_error_does_not_clear_session() {
2325        let _guard = TestAuthDirGuard::new();
2326        let session = sample_session();
2327        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2328
2329        let err =
2330            classify_refresh_status_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, r#"{"error": "internal_error"}"#);
2331        assert!(err.to_string().contains("HTTP 500"));
2332        // Transient error → session preserved for retry.
2333        assert!(
2334            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2335                .expect("load")
2336                .is_some()
2337        );
2338    }
2339
2340    #[test]
2341    fn classify_refresh_status_error_never_includes_raw_body() {
2342        let _guard = TestAuthDirGuard::new();
2343        let err = classify_refresh_status_error(
2344            reqwest::StatusCode::BAD_REQUEST,
2345            r#"{"error": "invalid_grant", "sensitive_data": "secret-leak-attempt"}"#,
2346        );
2347        assert!(!err.to_string().contains("secret-leak-attempt"), "raw body leaked into error: {err}");
2348    }
2349
2350    // ── Table-driven classification matrix (status × body shape × expected) ──
2351
2352    /// Expected outcome of `classify_refresh_status_error`.
2353    #[derive(Debug, PartialEq)]
2354    enum ClassifyOutcome {
2355        /// Session was cleared (terminal grant error).
2356        Terminal,
2357        /// Session was preserved; error message contains this fragment.
2358        Preserved(&'static str),
2359    }
2360
2361    fn run_classify_matrix(status: reqwest::StatusCode, body: &str, expected: ClassifyOutcome) {
2362        let _guard = TestAuthDirGuard::new();
2363        let session = sample_session();
2364        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2365
2366        let err = classify_refresh_status_error(status, body);
2367        let msg = err.to_string();
2368        let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File).expect("load");
2369
2370        match expected {
2371            ClassifyOutcome::Terminal => {
2372                assert!(msg.contains("session expired"), "terminal error should say 'session expired': {msg}");
2373                assert!(loaded.is_none(), "session should be cleared for terminal: {status} {body}");
2374            }
2375            ClassifyOutcome::Preserved(frag) => {
2376                assert!(!msg.contains("session expired"), "non-terminal should not say 'session expired': {msg}");
2377                assert!(loaded.is_some(), "session should be preserved for: {status} {body}");
2378                if !frag.is_empty() {
2379                    assert!(msg.contains(frag), "error should contain '{frag}': {msg}");
2380                }
2381            }
2382        }
2383    }
2384
2385    #[test]
2386    fn classify_matrix_terminal_400_flat() {
2387        run_classify_matrix(
2388            reqwest::StatusCode::BAD_REQUEST,
2389            r#"{"error": "invalid_grant"}"#,
2390            ClassifyOutcome::Terminal,
2391        );
2392    }
2393
2394    #[test]
2395    fn classify_matrix_terminal_400_nested_code() {
2396        run_classify_matrix(
2397            reqwest::StatusCode::BAD_REQUEST,
2398            r#"{"error": {"code": "invalid_grant", "message": "..."}}"#,
2399            ClassifyOutcome::Terminal,
2400        );
2401    }
2402
2403    #[test]
2404    fn classify_matrix_terminal_400_nested_type() {
2405        run_classify_matrix(
2406            reqwest::StatusCode::BAD_REQUEST,
2407            r#"{"error": {"type": "invalid_grant", "message": "..."}}"#,
2408            ClassifyOutcome::Terminal,
2409        );
2410    }
2411
2412    #[test]
2413    fn classify_matrix_terminal_400_toplevel_code() {
2414        run_classify_matrix(
2415            reqwest::StatusCode::BAD_REQUEST,
2416            r#"{"code": "refresh_token_revoked", "message": "..."}"#,
2417            ClassifyOutcome::Terminal,
2418        );
2419    }
2420
2421    #[test]
2422    fn classify_matrix_terminal_401_flat() {
2423        // Terminal codes on 401 should also clear the session.
2424        run_classify_matrix(
2425            reqwest::StatusCode::UNAUTHORIZED,
2426            r#"{"error": "invalid_token"}"#,
2427            ClassifyOutcome::Terminal,
2428        );
2429    }
2430
2431    #[test]
2432    fn classify_matrix_terminal_401_nested() {
2433        run_classify_matrix(
2434            reqwest::StatusCode::UNAUTHORIZED,
2435            r#"{"error": {"code": "refresh_token_expired"}}"#,
2436            ClassifyOutcome::Terminal,
2437        );
2438    }
2439
2440    #[test]
2441    fn classify_matrix_terminal_401_refresh_token_invalidated() {
2442        run_classify_matrix(
2443            reqwest::StatusCode::UNAUTHORIZED,
2444            r#"{"error": "refresh_token_invalidated"}"#,
2445            ClassifyOutcome::Terminal,
2446        );
2447    }
2448
2449    #[test]
2450    fn classify_matrix_terminal_400_toplevel_refresh_token_reused() {
2451        run_classify_matrix(
2452            reqwest::StatusCode::BAD_REQUEST,
2453            r#"{"code": "refresh_token_reused", "message": "..."}"#,
2454            ClassifyOutcome::Terminal,
2455        );
2456    }
2457
2458    #[test]
2459    fn classify_matrix_message_only_does_not_clear_session() {
2460        // A body with only a free-text "message" field (no structured code/type)
2461        // must NOT be treated as terminal, even if the message text happens to
2462        // contain a terminal code string. This prevents false-positive session
2463        // clearing from descriptive error messages.
2464        run_classify_matrix(
2465            reqwest::StatusCode::BAD_REQUEST,
2466            r#"{"error": {"message": "invalid_grant"}}"#,
2467            ClassifyOutcome::Preserved("HTTP 400"),
2468        );
2469    }
2470
2471    #[test]
2472    fn classify_matrix_invalid_client_preserves() {
2473        run_classify_matrix(
2474            reqwest::StatusCode::BAD_REQUEST,
2475            r#"{"error": "invalid_client"}"#,
2476            ClassifyOutcome::Preserved("invalid_client"),
2477        );
2478    }
2479
2480    #[test]
2481    fn classify_matrix_invalid_client_401_preserves() {
2482        run_classify_matrix(
2483            reqwest::StatusCode::UNAUTHORIZED,
2484            r#"{"error": "invalid_client"}"#,
2485            ClassifyOutcome::Preserved("invalid_client"),
2486        );
2487    }
2488
2489    #[test]
2490    fn classify_matrix_429_throttling_preserves() {
2491        run_classify_matrix(
2492            reqwest::StatusCode::TOO_MANY_REQUESTS,
2493            r#"{"error": "rate_limited"}"#,
2494            ClassifyOutcome::Preserved("rate-limited"),
2495        );
2496    }
2497
2498    #[test]
2499    fn classify_matrix_500_server_error_preserves() {
2500        run_classify_matrix(
2501            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
2502            r#"{"error": "internal_error"}"#,
2503            ClassifyOutcome::Preserved("HTTP 500"),
2504        );
2505    }
2506
2507    #[test]
2508    fn classify_matrix_502_bad_gateway_preserves() {
2509        run_classify_matrix(reqwest::StatusCode::BAD_GATEWAY, "", ClassifyOutcome::Preserved("HTTP 502"));
2510    }
2511
2512    #[test]
2513    fn classify_matrix_503_service_unavailable_preserves() {
2514        run_classify_matrix(reqwest::StatusCode::SERVICE_UNAVAILABLE, "", ClassifyOutcome::Preserved("HTTP 503"));
2515    }
2516
2517    #[test]
2518    fn classify_matrix_ambiguous_401_empty_body_preserves() {
2519        run_classify_matrix(reqwest::StatusCode::UNAUTHORIZED, "", ClassifyOutcome::Preserved("HTTP 401"));
2520    }
2521
2522    #[test]
2523    fn classify_matrix_400_nonterminal_code_preserves() {
2524        // A 400 with a code that is NOT in the terminal list should preserve.
2525        run_classify_matrix(
2526            reqwest::StatusCode::BAD_REQUEST,
2527            r#"{"error": "some_unknown_error"}"#,
2528            ClassifyOutcome::Preserved("HTTP 400"),
2529        );
2530    }
2531
2532    #[test]
2533    fn classify_matrix_never_leaks_body_in_any_branch() {
2534        // Terminal branch
2535        let _guard = TestAuthDirGuard::new();
2536        let err = classify_refresh_status_error(
2537            reqwest::StatusCode::BAD_REQUEST,
2538            r#"{"error": "invalid_grant", "leak": "TERMINAL_LEAK"}"#,
2539        );
2540        assert!(!err.to_string().contains("TERMINAL_LEAK"));
2541
2542        // Preserved branch (invalid_client)
2543        let err = classify_refresh_status_error(
2544            reqwest::StatusCode::BAD_REQUEST,
2545            r#"{"error": "invalid_client", "leak": "CLIENT_LEAK"}"#,
2546        );
2547        assert!(!err.to_string().contains("CLIENT_LEAK"));
2548
2549        // Preserved branch (429)
2550        let err = classify_refresh_status_error(
2551            reqwest::StatusCode::TOO_MANY_REQUESTS,
2552            r#"{"error": "rate_limited", "leak": "THROTTLE_LEAK"}"#,
2553        );
2554        assert!(!err.to_string().contains("THROTTLE_LEAK"));
2555    }
2556
2557    // ── Error code extraction shape tests ──
2558
2559    #[test]
2560    fn extract_error_code_nested_with_type_field() {
2561        let body = r#"{"error": {"type": "invalid_grant", "message": "..."}}"#;
2562        assert_eq!(extract_error_code(body), "invalid_grant");
2563    }
2564
2565    #[test]
2566    fn extract_error_code_toplevel_code_field() {
2567        let body = r#"{"code": "refresh_token_expired", "message": "..."}"#;
2568        assert_eq!(extract_error_code(body), "refresh_token_expired");
2569    }
2570
2571    #[test]
2572    fn extract_error_code_case_insensitive_normalization() {
2573        assert_eq!(extract_error_code(r#"{"error": "INVALID_GRANT"}"#), "invalid_grant");
2574    }
2575
2576    #[test]
2577    fn extract_error_code_no_substring_matching() {
2578        // "invalid_grant_really" is not a terminal code — extract_error_code
2579        // returns it verbatim, but classify won't match it as terminal.
2580        let code = extract_error_code(r#"{"error": "invalid_grant_really_not_a_real_code"}"#);
2581        assert_eq!(code, "invalid_grant_really_not_a_real_code");
2582        // Verify classify does NOT treat this as terminal.
2583        let _guard = TestAuthDirGuard::new();
2584        let session = sample_session();
2585        save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File).expect("save");
2586        let err = classify_refresh_status_error(
2587            reqwest::StatusCode::BAD_REQUEST,
2588            r#"{"error": "invalid_grant_really_not_a_real_code"}"#,
2589        );
2590        assert!(!err.to_string().contains("session expired"));
2591        assert!(
2592            load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
2593                .expect("load")
2594                .is_some()
2595        );
2596    }
2597
2598    // ── PKCE and callback Debug redaction tests ──
2599
2600    #[test]
2601    fn pkce_challenge_debug_redacts_verifier() {
2602        let challenge = generate_pkce_challenge().expect("generate pkce");
2603        let debug_str = format!("{challenge:?}");
2604        assert!(!debug_str.contains(&challenge.code_verifier), "code_verifier leaked: {debug_str}");
2605        assert!(debug_str.contains("<redacted>"), "verifier should be redacted: {debug_str}");
2606        // Challenge and method are safe to display.
2607        assert!(debug_str.contains(&challenge.code_challenge), "code_challenge should be visible: {debug_str}");
2608    }
2609
2610    #[test]
2611    fn auth_callback_outcome_debug_redacts_code() {
2612        let outcome = AuthCallbackOutcome::Code("super-secret-auth-code".to_string());
2613        let debug_str = format!("{outcome:?}");
2614        assert!(!debug_str.contains("super-secret-auth-code"), "authorization code leaked: {debug_str}");
2615        assert!(debug_str.contains("<redacted>"), "code should be redacted: {debug_str}");
2616    }
2617
2618    #[test]
2619    fn auth_callback_outcome_debug_shows_cancelled_and_redacts_error() {
2620        let cancelled = format!("{:?}", AuthCallbackOutcome::Cancelled);
2621        assert!(cancelled.contains("Cancelled"));
2622
2623        // Error messages from OAuth callbacks are untrusted query parameters
2624        // that may contain sensitive values — Debug must redact them.
2625        let error = format!("{:?}", AuthCallbackOutcome::Error("access_denied".to_string()));
2626        assert!(!error.contains("access_denied"), "error message leaked through Debug: {error}");
2627        assert!(error.contains("<redacted>"), "error should be redacted: {error}");
2628    }
2629}