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