Skip to main content

vtcode_auth/
codex_auth_import.rs

1//! Import ChatGPT OAuth credentials from the Codex CLI's `~/.codex/auth.json`.
2//!
3//! This implements the "reuse Codex auth.json" integration path described in
4//! the DeepWiki analysis of `openai/codex`: a third-party coding harness can
5//! read the OAuth tokens that `codex login` persisted and use them directly,
6//! avoiding a separate browser OAuth dance.
7//!
8//! ## How it works
9//!
10//! 1. `codex login` stores ChatGPT OAuth tokens (`id_token`, `access_token`,
11//!    `refresh_token`) and a derived `OPENAI_API_KEY` in
12//!    `$CODEX_HOME/auth.json` (`~/.codex/auth.json` by default).
13//! 2. VT Code reads that file, parses the token data, and converts it into an
14//!    [`OpenAIChatGptSession`] that the OpenAI provider can use directly.
15//! 3. VT Code deliberately does **not** rotate Codex-owned refresh tokens.
16//!    Copying or redeeming them could race Codex's refresh cycle or invalidate
17//!    Codex-maintained credentials. Instead, [`CodexAuthJsonRefresher`]
18//!    re-reads the auth.json file — which Codex refreshes independently — to
19//!    obtain fresh tokens when VT Code's session needs a refresh.
20//!
21//! Based on patterns from [openai/codex] (Apache-2.0). Copyright 2025 OpenAI.
22//! See the repository `THIRD-PARTY-NOTICES` file for full attribution.
23//!
24//! [openai/codex]: https://github.com/openai/codex
25
26use anyhow::{Context, Result, anyhow, bail};
27use async_trait::async_trait;
28use serde::Deserialize;
29use std::path::PathBuf;
30use std::sync::Arc;
31
32use crate::openai_chatgpt_oauth::{
33    OpenAIChatGptSession, OpenAIChatGptSessionRefresher, parse_jwt_claims, parse_jwt_exp,
34};
35
36/// Codex's `~/.codex/auth.json` structure (subset relevant to ChatGPT auth).
37///
38/// Mirrors `AuthDotJson` from `openai/codex` `codex-rs/login/src/auth/storage.rs`.
39/// Crate-private to avoid accidental token leakage through `Debug`.
40#[derive(Clone, Deserialize)]
41pub(crate) struct CodexAuthDotJson {
42    #[serde(default)]
43    pub auth_mode: Option<String>,
44    /// Derived API-key-style bearer token (serde-renamed to match Codex's file).
45    #[serde(rename = "OPENAI_API_KEY", default)]
46    pub openai_api_key: Option<String>,
47    #[serde(default)]
48    pub tokens: Option<CodexTokenData>,
49    /// ISO-8601 timestamp of the last token refresh (stored as a string by Codex).
50    #[serde(default)]
51    pub last_refresh: Option<String>,
52    /// Personal Access Token — presence (not value) indicates PAT mode.
53    /// Deserialized as a redacted sentinel to avoid storing the token value.
54    #[serde(default)]
55    pub personal_access_token: Option<RedactedPresence>,
56    /// Bedrock API key — presence (not value) indicates Bedrock mode.
57    #[serde(default)]
58    pub bedrock_api_key: Option<RedactedPresence>,
59}
60
61/// A deserialized value that only records whether the field was present,
62/// never the actual value. Used for PAT/Bedrock credentials in Codex's
63/// auth.json — we only need to know they exist for mode inference.
64#[derive(Clone)]
65pub(crate) struct RedactedPresence {
66    _present: bool,
67}
68
69impl RedactedPresence {
70    fn is_present(&self) -> bool {
71        self._present
72    }
73}
74
75impl<'de> Deserialize<'de> for RedactedPresence {
76    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
77    where
78        D: serde::Deserializer<'de>,
79    {
80        // Consume the value but discard it — we only care about presence.
81        let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
82        Ok(RedactedPresence { _present: true })
83    }
84}
85
86impl std::fmt::Debug for CodexAuthDotJson {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("CodexAuthDotJson")
89            .field("auth_mode", &self.auth_mode)
90            .field("openai_api_key", &self.openai_api_key.as_ref().map(|_| "<redacted>"))
91            .field("tokens", &self.tokens.as_ref().map(|_| "<redacted>"))
92            .field("last_refresh", &self.last_refresh)
93            .field("personal_access_token", &self.personal_access_token.as_ref().map(|_| "<present>"))
94            .field("bedrock_api_key", &self.bedrock_api_key.as_ref().map(|_| "<present>"))
95            .finish()
96    }
97}
98
99/// OAuth token data stored in Codex's auth.json.
100/// Crate-private to avoid accidental token leakage through `Debug`.
101#[derive(Clone, Deserialize)]
102pub(crate) struct CodexTokenData {
103    pub id_token: String,
104    pub access_token: String,
105    pub refresh_token: String,
106    #[serde(default)]
107    pub account_id: Option<String>,
108}
109
110impl std::fmt::Debug for CodexTokenData {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("CodexTokenData")
113            .field("id_token", &"<redacted>")
114            .field("access_token", &"<redacted>")
115            .field("refresh_token", &"<redacted>")
116            .field("account_id", &self.account_id)
117            .finish()
118    }
119}
120
121/// Resolve the Codex home directory (`CODEX_HOME` env var or `~/.codex`).
122pub fn codex_home_dir() -> Result<PathBuf> {
123    if let Ok(path) = std::env::var("CODEX_HOME")
124        && !path.is_empty()
125    {
126        return Ok(PathBuf::from(path));
127    }
128    dirs::home_dir()
129        .map(|home| home.join(".codex"))
130        .ok_or_else(|| anyhow!("could not determine home directory for codex auth path"))
131}
132
133/// Path to Codex's `auth.json`.
134pub fn codex_auth_json_path() -> Result<PathBuf> {
135    Ok(codex_home_dir()?.join("auth.json"))
136}
137
138/// Check whether Codex's `auth.json` exists on disk.
139pub fn codex_auth_json_exists() -> bool {
140    codex_auth_json_path().map(|path| path.exists()).unwrap_or(false)
141}
142
143/// Read and parse Codex's `auth.json`.
144///
145/// Crate-private: the error messages intentionally omit the absolute path to
146/// avoid leaking the user's home directory in logs or CLI output.
147pub(crate) fn read_codex_auth_json() -> Result<CodexAuthDotJson> {
148    let path = codex_auth_json_path()?;
149    let data = std::fs::read(&path).with_context(|| "failed to read codex auth.json")?;
150    serde_json::from_slice::<CodexAuthDotJson>(&data).with_context(|| "failed to parse codex auth.json")
151}
152
153/// Try to load a ChatGPT session from Codex's `auth.json` in a single attempt.
154///
155/// This is the retry-free primitive shared by the synchronous loader and the
156/// async [`CodexAuthJsonRefresher`]. It reads the file directly (no
157/// exists-precheck) so there is no TOCTOU window between stat and read.
158///
159/// Returns `Ok(Some(session))` if Codex has ChatGPT OAuth tokens, `Ok(None)` if
160/// the file does not exist, contains no token data, or is configured for a
161/// non-ChatGPT auth mode (e.g. API key). Returns `Err` if the file exists but
162/// cannot be read or parsed (transient I/O or partial-write conditions).
163///
164/// **Mode inference** mirrors Codex's own `resolved_mode` precedence:
165/// explicit `auth_mode` → `personal_access_token` → `bedrock_api_key` →
166/// `OPENAI_API_KEY` → ChatGPT (legacy default when none of the above are
167/// present). When `auth_mode` is absent, the presence of PAT, Bedrock, or
168/// OPENAI_API_KEY (even empty) selects a non-ChatGPT mode.
169fn try_load_codex_chatgpt_session_once() -> Result<Option<OpenAIChatGptSession>> {
170    let path = codex_auth_json_path()?;
171    // Read directly — no exists() precheck — to avoid a TOCTOU race where the
172    // file appears, then is truncated by a concurrent Codex refresh between the
173    // stat and the read. Map NotFound to Ok(None); all other errors propagate.
174    let data = match std::fs::read(&path) {
175        Ok(data) => data,
176        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
177        Err(_) => return Err(anyhow!("failed to read codex auth.json")),
178    };
179    let auth = serde_json::from_slice::<CodexAuthDotJson>(&data).with_context(|| "failed to parse codex auth.json")?;
180    // Reject explicitly non-ChatGPT auth modes (e.g. "apikey").
181    if let Some(mode) = auth.auth_mode.as_deref()
182        && !mode.eq_ignore_ascii_case("chatgpt")
183    {
184        return Ok(None);
185    }
186    // When auth_mode is absent, check Codex's resolved_mode precedence:
187    // PAT → Bedrock → OPENAI_API_KEY → ChatGPT. Presence of any of the
188    // non-ChatGPT credentials (even empty values) selects that mode.
189    if auth.auth_mode.is_none() {
190        if auth.personal_access_token.as_ref().is_some_and(|p| p.is_present()) {
191            return Ok(None);
192        }
193        if auth.bedrock_api_key.as_ref().is_some_and(|p| p.is_present()) {
194            return Ok(None);
195        }
196        if auth.openai_api_key.is_some() {
197            return Ok(None);
198        }
199    }
200    let Some(tokens) = &auth.tokens else {
201        return Ok(None);
202    };
203    // Require a nonblank access_token — it is the primary bearer credential.
204    if tokens.access_token.trim().is_empty() {
205        return Ok(None);
206    }
207    Ok(Some(codex_tokens_to_session(&auth, tokens)))
208}
209
210/// Try to load a ChatGPT session from Codex's `auth.json`.
211///
212/// Wraps the retry-free loader with a small bounded
213/// synchronous retry loop. Codex writes `auth.json` by truncating and
214/// rewriting without an atomic rename, so a concurrent Codex refresh can make
215/// the file momentarily empty or partial. The retry absorbs that transient
216/// window without sleeping when the file is absent (`Ok(None)`) or already
217/// successfully parsed (`Ok(Some(_))`).
218///
219/// Returns `Ok(Some(session))` if Codex has ChatGPT OAuth tokens, `Ok(None)` if
220/// the file does not exist, contains no token data, or is configured for a
221/// non-ChatGPT auth mode (e.g. API key). Returns `Err` if the file exists but
222/// cannot be read or parsed after all retries.
223pub fn try_load_codex_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
224    let mut last_err = None;
225    for attempt in 0..CODEX_REFRESH_MAX_ATTEMPTS {
226        match try_load_codex_chatgpt_session_once() {
227            Ok(result) => return Ok(result),
228            Err(err) => last_err = Some(err),
229        }
230        // Only sleep between attempts, not after the last one.
231        if attempt + 1 < CODEX_REFRESH_MAX_ATTEMPTS {
232            if let Some(&delay) = CODEX_REFRESH_BACKOFF_MS.get(attempt) {
233                std::thread::sleep(std::time::Duration::from_millis(delay));
234            }
235        }
236    }
237    Err(last_err.unwrap_or_else(|| anyhow!("failed to read codex auth.json after retries")))
238}
239
240/// Convert Codex auth tokens into a VT Code [`OpenAIChatGptSession`].
241///
242/// The Codex `refresh_token` is deliberately NOT copied into the session.
243/// Codex-owned tokens are not rotated by VT Code — copying or redeeming them
244/// could race Codex's refresh cycle or invalidate Codex-maintained credentials.
245/// The external refresher (`CodexAuthJsonRefresher`) re-reads `auth.json`
246/// instead. Storing an empty string prevents the Codex refresh token from
247/// lingering in memory or leaking through the session's `Debug` output.
248fn codex_tokens_to_session(auth: &CodexAuthDotJson, tokens: &CodexTokenData) -> OpenAIChatGptSession {
249    let now = now_secs();
250    // Parse JWT claims for email / account_id / plan (same logic as the OAuth flow).
251    let claims = parse_jwt_claims(&tokens.id_token).ok();
252    // Extract the `exp` claim from the access_token JWT (Codex doesn't store
253    // expiry separately). This lets the session detect expired tokens and
254    // trigger a refresh (reread of auth.json) rather than sending stale creds.
255    let expires_at = parse_jwt_exp(&tokens.access_token);
256    OpenAIChatGptSession {
257        // Use Codex's derived API key if present; the provider falls back to the
258        // OAuth access_token when this field is empty.
259        openai_api_key: auth.openai_api_key.clone().unwrap_or_default(),
260        id_token: tokens.id_token.clone(),
261        access_token: tokens.access_token.clone(),
262        // Deliberately empty — Codex-owned refresh tokens are not copied or
263        // rotated by VT Code (ownership/race-avoidance, see module docs).
264        refresh_token: String::new(),
265        account_id: tokens
266            .account_id
267            .clone()
268            .or_else(|| claims.as_ref().and_then(|c| c.account_id.clone())),
269        email: claims.as_ref().and_then(|c| c.email.clone()),
270        plan: claims.as_ref().and_then(|c| c.plan.clone()),
271        // Treat the imported session as freshly obtained so it is not
272        // immediately refreshed (the tokens are valid bearer tokens).
273        obtained_at: now,
274        refreshed_at: now,
275        expires_at,
276    }
277}
278
279/// Check whether a session's access token has expired.
280///
281/// Uses `expires_at` (from the JWT `exp` claim) with a safety skew. Returns
282/// `false` when `expires_at` is `None` (expiry unknown — assume valid).
283pub(crate) fn is_session_expired(session: &OpenAIChatGptSession) -> bool {
284    let Some(expires_at) = session.expires_at else {
285        return false;
286    };
287    now_secs().saturating_add(60) >= expires_at
288}
289
290/// A session refresher that re-reads Codex's `auth.json` to obtain fresh tokens.
291///
292/// Codex-owned refresh tokens are not rotated by VT Code — copying or
293/// redeeming them could race Codex's refresh cycle or invalidate
294/// Codex-maintained credentials. Instead, this refresher relies on Codex
295/// refreshing its auth.json independently (e.g. when Codex is running) and
296/// re-reads the file.
297///
298/// Codex writes `auth.json` by truncating and rewriting without an atomic
299/// rename, so a single read can observe an empty or partial file during a
300/// concurrent Codex refresh. The refresher retries a few times with short
301/// backoff before giving up, and only replaces the in-memory session after a
302/// complete, valid parse.
303pub struct CodexAuthJsonRefresher;
304
305const CODEX_REFRESH_MAX_ATTEMPTS: usize = 4;
306const CODEX_REFRESH_BACKOFF_MS: &[u64] = &[10, 30, 100];
307
308#[async_trait]
309impl OpenAIChatGptSessionRefresher for CodexAuthJsonRefresher {
310    async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
311        let mut last_err = None;
312        for attempt in 0..CODEX_REFRESH_MAX_ATTEMPTS {
313            // Use the retry-free primitive — this fn owns the async retry loop.
314            // Calling the public try_load_codex_chatgpt_session() here would
315            // multiply delays (sync retries inside async retries).
316            match try_load_codex_chatgpt_session_once() {
317                Ok(Some(session)) => {
318                    // Reject any known-expired token. If it's the same as the
319                    // current token, Codex hasn't refreshed the file. If it's
320                    // different but also expired, it's a rotated-but-stale
321                    // replacement — still not usable.
322                    if is_session_expired(&session) {
323                        if session.access_token == current.access_token {
324                            bail!(
325                                "Codex's auth.json contains an expired access token that has not been refreshed. \
326                                 Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
327                            );
328                        }
329                        bail!(
330                            "Codex's auth.json contains an expired replacement access token. \
331                             Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
332                        );
333                    }
334                    return Ok(session);
335                }
336                Ok(None) => {
337                    // File missing, no tokens, or non-ChatGPT mode — no point retrying.
338                    bail!(
339                        "Codex auth.json no longer contains ChatGPT tokens. \
340                         Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
341                    );
342                }
343                Err(err) => {
344                    // Transient I/O or parse failure — likely a partial write.
345                    last_err = Some(err);
346                    if attempt + 1 < CODEX_REFRESH_MAX_ATTEMPTS {
347                        let delay = CODEX_REFRESH_BACKOFF_MS.get(attempt).copied().unwrap_or(100);
348                        tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
349                    }
350                }
351            }
352        }
353        // All retries exhausted — propagate the last transient error.
354        Err(last_err.unwrap_or_else(|| anyhow!("failed to read codex auth.json after retries")))
355    }
356}
357
358/// Create a shared [`CodexAuthJsonRefresher`] handle for use with the external
359/// constructor on [`crate::OpenAIChatGptAuthHandle`].
360pub fn codex_auth_json_refresher() -> Arc<dyn OpenAIChatGptSessionRefresher> {
361    Arc::new(CodexAuthJsonRefresher)
362}
363
364fn now_secs() -> u64 {
365    std::time::SystemTime::now()
366        .duration_since(std::time::UNIX_EPOCH)
367        .map(|duration| duration.as_secs())
368        .unwrap_or(0)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serial_test::serial;
375
376    #[test]
377    fn parse_codex_auth_json_with_tokens() {
378        let json = r#"{
379            "OPENAI_API_KEY": "sk-derived-key",
380            "tokens": {
381                "id_token": "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
382                "access_token": "oauth-access",
383                "refresh_token": "oauth-refresh",
384                "account_id": "acc-123"
385            },
386            "last_refresh": "2025-01-15T12:00:00Z"
387        }"#;
388        let auth: CodexAuthDotJson = serde_json::from_str(json).expect("parse");
389        assert_eq!(auth.openai_api_key.as_deref(), Some("sk-derived-key"));
390        let tokens = auth.tokens.expect("tokens present");
391        assert_eq!(tokens.access_token, "oauth-access");
392        assert_eq!(tokens.refresh_token, "oauth-refresh");
393        assert_eq!(tokens.account_id.as_deref(), Some("acc-123"));
394    }
395
396    #[test]
397    fn parse_codex_auth_json_without_tokens() {
398        let json = r#"{"OPENAI_API_KEY": "sk-key"}"#;
399        let auth: CodexAuthDotJson = serde_json::from_str(json).expect("parse");
400        assert!(auth.tokens.is_none());
401    }
402
403    #[test]
404    fn codex_tokens_to_session_maps_fields() {
405        let auth = CodexAuthDotJson {
406            auth_mode: Some("chatgpt".to_string()),
407            openai_api_key: Some("sk-derived".to_string()),
408            tokens: Some(CodexTokenData {
409                id_token: "header.eyJlbWFpbCI6InVzZXJAdGVzdC5jb20ifQ.sig".to_string(),
410                access_token: "access-tok".to_string(),
411                refresh_token: "refresh-tok".to_string(),
412                account_id: Some("acc-456".to_string()),
413            }),
414            last_refresh: None,
415            personal_access_token: None,
416            bedrock_api_key: None,
417        };
418        let tokens = auth.tokens.clone().unwrap();
419        let session = codex_tokens_to_session(&auth, &tokens);
420        assert_eq!(session.openai_api_key, "sk-derived");
421        assert_eq!(session.access_token, "access-tok");
422        // refresh_token is deliberately empty — Codex-owned refresh tokens
423        // are not copied into the VTCode session (ownership/race-avoidance).
424        assert_eq!(session.refresh_token, "");
425        assert_eq!(session.account_id.as_deref(), Some("acc-456"));
426        // email is parsed from the JWT payload
427        assert_eq!(session.email.as_deref(), Some("user@test.com"));
428    }
429
430    #[test]
431    fn codex_tokens_to_session_falls_back_to_empty_api_key() {
432        let auth = CodexAuthDotJson {
433            auth_mode: None,
434            openai_api_key: None,
435            tokens: Some(CodexTokenData {
436                id_token: "header.e30.sig".to_string(),
437                access_token: "access-tok".to_string(),
438                refresh_token: "refresh-tok".to_string(),
439                account_id: None,
440            }),
441            last_refresh: None,
442            personal_access_token: None,
443            bedrock_api_key: None,
444        };
445        let tokens = auth.tokens.clone().unwrap();
446        let session = codex_tokens_to_session(&auth, &tokens);
447        assert!(session.openai_api_key.is_empty());
448        assert!(session.account_id.is_none());
449    }
450
451    #[tokio::test]
452    #[serial]
453    async fn codex_refresher_fails_when_no_auth_file() {
454        // Point CODEX_HOME at an empty temp dir so the refresher cannot find auth.json.
455        let temp = tempfile::tempdir().expect("create temp dir");
456        let prev = std::env::var("CODEX_HOME").ok();
457        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
458        let refresher = CodexAuthJsonRefresher;
459        let session = OpenAIChatGptSession {
460            openai_api_key: String::new(),
461            id_token: String::new(),
462            access_token: String::new(),
463            refresh_token: String::new(),
464            account_id: None,
465            email: None,
466            plan: None,
467            obtained_at: 0,
468            refreshed_at: 0,
469            expires_at: None,
470        };
471        let result = refresher.refresh_session(&session).await;
472        // Restore the env var.
473        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
474        assert!(result.is_err());
475        assert!(result.unwrap_err().to_string().contains("no longer contains ChatGPT tokens"));
476    }
477
478    #[test]
479    #[serial]
480    fn codex_apikey_mode_is_rejected_as_chatgpt_fallback() {
481        // auth_mode = "apikey" must not produce a ChatGPT session.
482        let temp = tempfile::tempdir().expect("create temp dir");
483        let prev = std::env::var("CODEX_HOME").ok();
484        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
485        let auth_json = r#"{
486            "auth_mode": "apikey",
487            "OPENAI_API_KEY": "sk-test",
488            "tokens": {
489                "id_token": "header.e30.sig",
490                "access_token": "oauth-access",
491                "refresh_token": "oauth-refresh"
492            }
493        }"#;
494        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
495        let session = try_load_codex_chatgpt_session();
496        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
497        assert!(session.is_ok(), "parsing should not error");
498        assert!(session.unwrap().is_none(), "apikey mode should not produce a ChatGPT session");
499    }
500
501    #[test]
502    #[serial]
503    fn codex_blank_access_token_is_rejected() {
504        let temp = tempfile::tempdir().expect("create temp dir");
505        let prev = std::env::var("CODEX_HOME").ok();
506        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
507        let auth_json = r#"{
508            "auth_mode": "chatgpt",
509            "tokens": {
510                "id_token": "header.e30.sig",
511                "access_token": "   ",
512                "refresh_token": "oauth-refresh"
513            }
514        }"#;
515        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
516        let session = try_load_codex_chatgpt_session();
517        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
518        assert!(session.is_ok());
519        assert!(session.unwrap().is_none(), "blank access_token should not produce a session");
520    }
521
522    #[test]
523    #[serial]
524    fn codex_chatgpt_mode_with_access_token_is_accepted() {
525        let temp = tempfile::tempdir().expect("create temp dir");
526        let prev = std::env::var("CODEX_HOME").ok();
527        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
528        // Even without a derived API key, a valid access_token is sufficient.
529        let auth_json = r#"{
530            "auth_mode": "chatgpt",
531            "tokens": {
532                "id_token": "header.e30.sig",
533                "access_token": "oauth-access-token",
534                "refresh_token": "oauth-refresh"
535            }
536        }"#;
537        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
538        let session = try_load_codex_chatgpt_session();
539        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
540        let session = session.expect("parse ok").expect("session should be present");
541        assert_eq!(session.access_token, "oauth-access-token");
542        assert!(session.openai_api_key.is_empty(), "no derived API key in this fixture");
543    }
544
545    #[test]
546    #[serial]
547    fn codex_no_auth_mode_with_api_key_is_rejected() {
548        // When auth_mode is absent and OPENAI_API_KEY is present (even empty),
549        // Codex treats it as API-key mode — not a ChatGPT session. VT Code must
550        // match this (presence-based, not blank-based).
551        let temp = tempfile::tempdir().expect("create temp dir");
552        let prev = std::env::var("CODEX_HOME").ok();
553        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
554        let auth_json = r#"{
555            "OPENAI_API_KEY": "",
556            "tokens": {
557                "id_token": "header.e30.sig",
558                "access_token": "oauth-access",
559                "refresh_token": "oauth-refresh"
560            }
561        }"#;
562        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
563        let session = try_load_codex_chatgpt_session();
564        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
565        assert!(session.is_ok());
566        assert!(
567            session.unwrap().is_none(),
568            "missing auth_mode + present (even empty) API key should be API-key mode, not ChatGPT"
569        );
570    }
571
572    #[test]
573    #[serial]
574    fn codex_no_auth_mode_no_api_key_defaults_to_chatgpt() {
575        // When auth_mode is absent AND no OPENAI_API_KEY, legacy default is ChatGPT.
576        let temp = tempfile::tempdir().expect("create temp dir");
577        let prev = std::env::var("CODEX_HOME").ok();
578        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
579        let auth_json = r#"{
580            "tokens": {
581                "id_token": "header.e30.sig",
582                "access_token": "oauth-access",
583                "refresh_token": "oauth-refresh"
584            }
585        }"#;
586        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
587        let session = try_load_codex_chatgpt_session();
588        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
589        assert!(session.is_ok());
590        assert!(session.unwrap().is_some(), "missing auth_mode + no API key + tokens should default to ChatGPT");
591    }
592
593    // ── PAT / Bedrock mode inference (verified against openai/codex source) ──
594
595    #[test]
596    #[serial]
597    fn codex_pat_presence_suppresses_chatgpt_fallback() {
598        // When auth_mode is absent and personal_access_token is present,
599        // Codex's resolved_mode returns PersonalAccessToken — not ChatGPT.
600        // VT Code must reject this as a ChatGPT session.
601        let temp = tempfile::tempdir().expect("create temp dir");
602        let prev = std::env::var("CODEX_HOME").ok();
603        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
604        let auth_json = r#"{
605            "personal_access_token": "pat-secret-value",
606            "tokens": {
607                "id_token": "header.e30.sig",
608                "access_token": "oauth-access",
609                "refresh_token": "oauth-refresh"
610            }
611        }"#;
612        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
613        let session = try_load_codex_chatgpt_session();
614        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
615        assert!(session.is_ok());
616        assert!(session.unwrap().is_none(), "PAT presence (even with tokens) should suppress ChatGPT fallback");
617    }
618
619    #[test]
620    #[serial]
621    fn codex_bedrock_presence_suppresses_chatgpt_fallback() {
622        // When auth_mode is absent and bedrock_api_key is present,
623        // Codex's resolved_mode returns BedrockApiKey — not ChatGPT.
624        let temp = tempfile::tempdir().expect("create temp dir");
625        let prev = std::env::var("CODEX_HOME").ok();
626        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
627        let auth_json = r#"{
628            "bedrock_api_key": {"api_key": "bedrock-secret", "region": "us-east-1"},
629            "tokens": {
630                "id_token": "header.e30.sig",
631                "access_token": "oauth-access",
632                "refresh_token": "oauth-refresh"
633            }
634        }"#;
635        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
636        let session = try_load_codex_chatgpt_session();
637        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
638        assert!(session.is_ok());
639        assert!(session.unwrap().is_none(), "Bedrock presence (even with tokens) should suppress ChatGPT fallback");
640    }
641
642    #[test]
643    #[serial]
644    fn codex_explicit_chatgpt_mode_overrides_pat_presence() {
645        // Explicit auth_mode = "chatgpt" wins over PAT presence, matching
646        // Codex's resolved_mode: explicit auth_mode is checked first.
647        let temp = tempfile::tempdir().expect("create temp dir");
648        let prev = std::env::var("CODEX_HOME").ok();
649        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
650        let auth_json = r#"{
651            "auth_mode": "chatgpt",
652            "personal_access_token": "pat-should-be-ignored",
653            "tokens": {
654                "id_token": "header.e30.sig",
655                "access_token": "oauth-access",
656                "refresh_token": "oauth-refresh"
657            }
658        }"#;
659        std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
660        let session = try_load_codex_chatgpt_session();
661        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
662        let session = session.expect("parse ok").expect("session should be present");
663        assert_eq!(session.access_token, "oauth-access");
664    }
665
666    #[test]
667    fn codex_auth_json_debug_never_leaks_token_values() {
668        // The custom Debug for CodexAuthDotJson must redact all credential values.
669        let auth = CodexAuthDotJson {
670            auth_mode: Some("chatgpt".to_string()),
671            openai_api_key: Some("sk-secret-key".to_string()),
672            tokens: Some(CodexTokenData {
673                id_token: "id-secret".to_string(),
674                access_token: "access-secret".to_string(),
675                refresh_token: "refresh-secret".to_string(),
676                account_id: Some("acc-123".to_string()),
677            }),
678            last_refresh: None,
679            personal_access_token: Some(RedactedPresence { _present: true }),
680            bedrock_api_key: Some(RedactedPresence { _present: true }),
681        };
682        let debug_str = format!("{auth:?}");
683        assert!(!debug_str.contains("sk-secret-key"), "api key leaked: {debug_str}");
684        assert!(!debug_str.contains("id-secret"), "id_token leaked: {debug_str}");
685        assert!(!debug_str.contains("access-secret"), "access_token leaked: {debug_str}");
686        assert!(!debug_str.contains("refresh-secret"), "refresh_token leaked: {debug_str}");
687        // Non-secret metadata should still be visible.
688        assert!(debug_str.contains("chatgpt"), "auth_mode should be visible: {debug_str}");
689        // account_id is inside the redacted tokens field, so it should NOT appear.
690        assert!(!debug_str.contains("acc-123"), "account_id inside tokens should be redacted: {debug_str}");
691    }
692
693    // ── Helper: build a JWT with an `exp` claim for expiry testing ──
694
695    fn make_jwt_with_exp(exp: u64) -> String {
696        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
697        let payload = format!(r#"{{"exp":{exp}}}"#);
698        let encoded = URL_SAFE_NO_PAD.encode(payload.as_bytes());
699        format!("header.{encoded}.sig")
700    }
701
702    fn make_jwt_without_exp() -> String {
703        // Opaque token with no exp claim — simulates non-JWT access tokens.
704        "opaque-access-token-not-a-jwt".to_string()
705    }
706
707    fn write_codex_auth_json(dir: &std::path::Path, json: &str) {
708        std::fs::write(dir.join("auth.json"), json).expect("write auth.json");
709    }
710
711    fn codex_auth_json_with_access_token(access_token: &str) -> String {
712        format!(
713            r#"{{"auth_mode":"chatgpt","tokens":{{"id_token":"header.e30.sig","access_token":"{access_token}","refresh_token":"oauth-refresh"}}}}"#
714        )
715    }
716
717    // ── Initial-load retry tests ──
718
719    #[test]
720    #[serial]
721    fn initial_load_returns_ok_none_when_file_absent() {
722        // No file at all — should return Ok(None) immediately, no error.
723        let temp = tempfile::tempdir().expect("create temp dir");
724        let prev = std::env::var("CODEX_HOME").ok();
725        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
726        let result = try_load_codex_chatgpt_session();
727        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
728        assert!(result.is_ok(), "missing file should be Ok(None), not Err");
729        assert!(result.unwrap().is_none());
730    }
731
732    #[test]
733    #[serial]
734    fn initial_load_retries_partial_write_then_succeeds() {
735        // Simulate a concurrent Codex refresh: file starts as truncated/invalid
736        // JSON, then becomes valid. The bounded retry should absorb the partial
737        // write and return the session once the file is complete.
738        let temp = tempfile::tempdir().expect("create temp dir");
739        let prev = std::env::var("CODEX_HOME").ok();
740        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
741        // Write a truncated file (partial write — invalid JSON).
742        write_codex_auth_json(temp.path(), r#"{"auth_mode":"chatg"#);
743        // Spawn a thread that completes the file after a short delay.
744        let path = temp.path().join("auth.json");
745        drop(std::thread::spawn(move || {
746            std::thread::sleep(std::time::Duration::from_millis(15));
747            std::fs::write(&path, codex_auth_json_with_access_token("oauth-access")).expect("complete the file");
748        }));
749        let result = try_load_codex_chatgpt_session();
750        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
751        let session = result.expect("should succeed after retry");
752        assert!(session.is_some(), "retry should absorb the partial write and return the session");
753        assert_eq!(session.unwrap().access_token, "oauth-access");
754    }
755
756    #[test]
757    #[serial]
758    fn initial_load_returns_path_neutral_error_after_retries_exhausted() {
759        // File is permanently invalid JSON — retries should exhaust and return
760        // a path-neutral parse error (no absolute home path in the message).
761        let temp = tempfile::tempdir().expect("create temp dir");
762        let prev = std::env::var("CODEX_HOME").ok();
763        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
764        write_codex_auth_json(temp.path(), "this is not json at all {{{{");
765        let result = try_load_codex_chatgpt_session();
766        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
767        let err = result.expect_err("permanently invalid JSON should error");
768        let msg = err.to_string();
769        assert!(msg.contains("failed to parse codex auth.json"), "error should mention parse failure: {msg}");
770        // Path-neutral: the error must not contain the temp dir path or "codex" path.
771        assert!(!msg.contains(temp.path().to_str().unwrap()), "error must not leak absolute path: {msg}");
772    }
773
774    // ── Refresher expiry tests ──
775
776    #[tokio::test]
777    #[serial]
778    async fn refresher_rejects_changed_but_expired_access_token() {
779        let temp = tempfile::tempdir().expect("create temp dir");
780        let prev = std::env::var("CODEX_HOME").ok();
781        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
782        // Current session has an expired token.
783        let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
784        let current = OpenAIChatGptSession {
785            openai_api_key: String::new(),
786            id_token: String::new(),
787            access_token: expired_jwt.clone(),
788            refresh_token: String::new(),
789            account_id: None,
790            email: None,
791            plan: None,
792            obtained_at: 0,
793            refreshed_at: 0,
794            expires_at: Some(now_secs().saturating_sub(3600)),
795        };
796        // Codex "refreshed" the file with a different but also-expired token.
797        let new_expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(1800));
798        write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&new_expired_jwt));
799        let refresher = CodexAuthJsonRefresher;
800        let result = refresher.refresh_session(&current).await;
801        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
802        let err = result.expect_err("expired replacement should be rejected");
803        assert!(
804            err.to_string().contains("expired replacement access token"),
805            "should mention expired replacement: {err}"
806        );
807    }
808
809    #[tokio::test]
810    #[serial]
811    async fn refresher_accepts_changed_valid_access_token() {
812        let temp = tempfile::tempdir().expect("create temp dir");
813        let prev = std::env::var("CODEX_HOME").ok();
814        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
815        // Current session has an expired token.
816        let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
817        let current = OpenAIChatGptSession {
818            openai_api_key: String::new(),
819            id_token: String::new(),
820            access_token: expired_jwt,
821            refresh_token: String::new(),
822            account_id: None,
823            email: None,
824            plan: None,
825            obtained_at: 0,
826            refreshed_at: 0,
827            expires_at: Some(now_secs().saturating_sub(3600)),
828        };
829        // Codex refreshed the file with a new valid (future-expiry) token.
830        let valid_jwt = make_jwt_with_exp(now_secs().saturating_add(3600));
831        write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&valid_jwt));
832        let refresher = CodexAuthJsonRefresher;
833        let result = refresher.refresh_session(&current).await;
834        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
835        let session = result.expect("valid replacement should be accepted");
836        assert!(!session.access_token.is_empty(), "should return the refreshed session");
837    }
838
839    #[tokio::test]
840    #[serial]
841    async fn refresher_rejects_unchanged_expired_access_token() {
842        let temp = tempfile::tempdir().expect("create temp dir");
843        let prev = std::env::var("CODEX_HOME").ok();
844        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
845        let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
846        let current = OpenAIChatGptSession {
847            openai_api_key: String::new(),
848            id_token: String::new(),
849            access_token: expired_jwt.clone(),
850            refresh_token: String::new(),
851            account_id: None,
852            email: None,
853            plan: None,
854            obtained_at: 0,
855            refreshed_at: 0,
856            expires_at: Some(now_secs().saturating_sub(3600)),
857        };
858        // File has the SAME expired token — Codex hasn't refreshed it.
859        write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&expired_jwt));
860        let refresher = CodexAuthJsonRefresher;
861        let result = refresher.refresh_session(&current).await;
862        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
863        let err = result.expect_err("unchanged expired token should be rejected");
864        assert!(err.to_string().contains("has not been refreshed"), "should mention Codex hasn't refreshed: {err}");
865    }
866
867    // ── Unknown-expiry behavior ──
868
869    #[test]
870    #[serial]
871    fn unknown_expiry_session_is_treated_as_valid() {
872        // An opaque (non-JWT) access token has no exp claim → expires_at = None.
873        // The session should still load and is_session_expired should return false.
874        let temp = tempfile::tempdir().expect("create temp dir");
875        let prev = std::env::var("CODEX_HOME").ok();
876        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
877        let opaque_token = make_jwt_without_exp();
878        write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&opaque_token));
879        let session = try_load_codex_chatgpt_session();
880        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
881        let session = session.expect("parse ok").expect("session present");
882        assert!(session.expires_at.is_none(), "opaque token should have no expiry");
883        assert!(!is_session_expired(&session), "unknown expiry should be treated as valid (not expired)");
884    }
885
886    #[tokio::test]
887    #[serial]
888    async fn refresher_accepts_unknown_expiry_token() {
889        // An opaque token with no exp claim should be accepted by the refresher
890        // (unknown expiry is treated as valid, matching the session-expiry policy).
891        let temp = tempfile::tempdir().expect("create temp dir");
892        let prev = std::env::var("CODEX_HOME").ok();
893        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
894        let opaque_token = make_jwt_without_exp();
895        write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&opaque_token));
896        let current = OpenAIChatGptSession {
897            openai_api_key: String::new(),
898            id_token: String::new(),
899            access_token: "previous-opaque".to_string(),
900            refresh_token: String::new(),
901            account_id: None,
902            email: None,
903            plan: None,
904            obtained_at: 0,
905            refreshed_at: 0,
906            expires_at: None,
907        };
908        let refresher = CodexAuthJsonRefresher;
909        let result = refresher.refresh_session(&current).await;
910        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
911        let session = result.expect("unknown-expiry token should be accepted");
912        assert_eq!(session.access_token, opaque_token);
913    }
914
915    // ── Refresher does not multiply retry loops ──
916
917    #[tokio::test]
918    #[serial]
919    async fn refresher_uses_single_attempt_primitive() {
920        // If the refresher called the public try_load_codex_chatgpt_session()
921        // (which has its own sync retry loop), a permanently-invalid file would
922        // incur sync sleeps INSIDE each async attempt, multiplying delays.
923        // This test verifies the refresher completes quickly even with a
924        // permanently invalid file, proving it uses the single-attempt primitive.
925        let temp = tempfile::tempdir().expect("create temp dir");
926        let prev = std::env::var("CODEX_HOME").ok();
927        vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
928        write_codex_auth_json(temp.path(), "invalid json {{{{");
929        let current = OpenAIChatGptSession {
930            openai_api_key: String::new(),
931            id_token: String::new(),
932            access_token: "old".to_string(),
933            refresh_token: String::new(),
934            account_id: None,
935            email: None,
936            plan: None,
937            obtained_at: 0,
938            refreshed_at: 0,
939            expires_at: None,
940        };
941        let refresher = CodexAuthJsonRefresher;
942        let start = std::time::Instant::now();
943        let result = refresher.refresh_session(&current).await;
944        let elapsed = start.elapsed();
945        vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
946        assert!(result.is_err(), "invalid file should error");
947        // The async refresher sleeps 10+30+100=140ms across its 4 attempts.
948        // If it also called the sync retry wrapper (which sleeps the same),
949        // total would be ~280ms+. We allow 200ms as a generous upper bound
950        // that proves no double-retry.
951        assert!(
952            elapsed < std::time::Duration::from_millis(200),
953            "refresher should not multiply retry delays (took {elapsed:?})"
954        );
955    }
956}