use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use crate::openai_chatgpt_oauth::{
OpenAIChatGptSession, OpenAIChatGptSessionRefresher, parse_jwt_claims, parse_jwt_exp,
};
#[derive(Clone, Deserialize)]
pub(crate) struct CodexAuthDotJson {
#[serde(default)]
pub auth_mode: Option<String>,
#[serde(rename = "OPENAI_API_KEY", default)]
pub openai_api_key: Option<String>,
#[serde(default)]
pub tokens: Option<CodexTokenData>,
#[serde(default)]
pub last_refresh: Option<String>,
#[serde(default)]
pub personal_access_token: Option<RedactedPresence>,
#[serde(default)]
pub bedrock_api_key: Option<RedactedPresence>,
}
#[derive(Clone)]
pub(crate) struct RedactedPresence {
_present: bool,
}
impl RedactedPresence {
fn is_present(&self) -> bool {
self._present
}
}
impl<'de> Deserialize<'de> for RedactedPresence {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(RedactedPresence { _present: true })
}
}
impl std::fmt::Debug for CodexAuthDotJson {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CodexAuthDotJson")
.field("auth_mode", &self.auth_mode)
.field("openai_api_key", &self.openai_api_key.as_ref().map(|_| "<redacted>"))
.field("tokens", &self.tokens.as_ref().map(|_| "<redacted>"))
.field("last_refresh", &self.last_refresh)
.field("personal_access_token", &self.personal_access_token.as_ref().map(|_| "<present>"))
.field("bedrock_api_key", &self.bedrock_api_key.as_ref().map(|_| "<present>"))
.finish()
}
}
#[derive(Clone, Deserialize)]
pub(crate) struct CodexTokenData {
pub id_token: String,
pub access_token: String,
pub refresh_token: String,
#[serde(default)]
pub account_id: Option<String>,
}
impl std::fmt::Debug for CodexTokenData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CodexTokenData")
.field("id_token", &"<redacted>")
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("account_id", &self.account_id)
.finish()
}
}
pub fn codex_home_dir() -> Result<PathBuf> {
if let Ok(path) = std::env::var("CODEX_HOME")
&& !path.is_empty()
{
return Ok(PathBuf::from(path));
}
dirs::home_dir()
.map(|home| home.join(".codex"))
.ok_or_else(|| anyhow!("could not determine home directory for codex auth path"))
}
pub fn codex_auth_json_path() -> Result<PathBuf> {
Ok(codex_home_dir()?.join("auth.json"))
}
pub fn codex_auth_json_exists() -> bool {
codex_auth_json_path().map(|path| path.exists()).unwrap_or(false)
}
pub(crate) fn read_codex_auth_json() -> Result<CodexAuthDotJson> {
let path = codex_auth_json_path()?;
let data = std::fs::read(&path).with_context(|| "failed to read codex auth.json")?;
serde_json::from_slice::<CodexAuthDotJson>(&data).with_context(|| "failed to parse codex auth.json")
}
fn try_load_codex_chatgpt_session_once() -> Result<Option<OpenAIChatGptSession>> {
let path = codex_auth_json_path()?;
let data = match std::fs::read(&path) {
Ok(data) => data,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err(anyhow!("failed to read codex auth.json")),
};
let auth = serde_json::from_slice::<CodexAuthDotJson>(&data).with_context(|| "failed to parse codex auth.json")?;
if let Some(mode) = auth.auth_mode.as_deref()
&& !mode.eq_ignore_ascii_case("chatgpt")
{
return Ok(None);
}
if auth.auth_mode.is_none() {
if auth.personal_access_token.as_ref().is_some_and(|p| p.is_present()) {
return Ok(None);
}
if auth.bedrock_api_key.as_ref().is_some_and(|p| p.is_present()) {
return Ok(None);
}
if auth.openai_api_key.is_some() {
return Ok(None);
}
}
let Some(tokens) = &auth.tokens else {
return Ok(None);
};
if tokens.access_token.trim().is_empty() {
return Ok(None);
}
Ok(Some(codex_tokens_to_session(&auth, tokens)))
}
pub fn try_load_codex_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
let mut last_err = None;
for attempt in 0..CODEX_REFRESH_MAX_ATTEMPTS {
match try_load_codex_chatgpt_session_once() {
Ok(result) => return Ok(result),
Err(err) => last_err = Some(err),
}
if attempt + 1 < CODEX_REFRESH_MAX_ATTEMPTS {
if let Some(&delay) = CODEX_REFRESH_BACKOFF_MS.get(attempt) {
std::thread::sleep(std::time::Duration::from_millis(delay));
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("failed to read codex auth.json after retries")))
}
fn codex_tokens_to_session(auth: &CodexAuthDotJson, tokens: &CodexTokenData) -> OpenAIChatGptSession {
let now = now_secs();
let claims = parse_jwt_claims(&tokens.id_token).ok();
let expires_at = parse_jwt_exp(&tokens.access_token);
OpenAIChatGptSession {
openai_api_key: auth.openai_api_key.clone().unwrap_or_default(),
id_token: tokens.id_token.clone(),
access_token: tokens.access_token.clone(),
refresh_token: String::new(),
account_id: tokens
.account_id
.clone()
.or_else(|| claims.as_ref().and_then(|c| c.account_id.clone())),
email: claims.as_ref().and_then(|c| c.email.clone()),
plan: claims.as_ref().and_then(|c| c.plan.clone()),
obtained_at: now,
refreshed_at: now,
expires_at,
}
}
pub(crate) fn is_session_expired(session: &OpenAIChatGptSession) -> bool {
let Some(expires_at) = session.expires_at else {
return false;
};
now_secs().saturating_add(60) >= expires_at
}
pub struct CodexAuthJsonRefresher;
const CODEX_REFRESH_MAX_ATTEMPTS: usize = 4;
const CODEX_REFRESH_BACKOFF_MS: &[u64] = &[10, 30, 100];
#[async_trait]
impl OpenAIChatGptSessionRefresher for CodexAuthJsonRefresher {
async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
let mut last_err = None;
for attempt in 0..CODEX_REFRESH_MAX_ATTEMPTS {
match try_load_codex_chatgpt_session_once() {
Ok(Some(session)) => {
if is_session_expired(&session) {
if session.access_token == current.access_token {
bail!(
"Codex's auth.json contains an expired access token that has not been refreshed. \
Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
);
}
bail!(
"Codex's auth.json contains an expired replacement access token. \
Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
);
}
return Ok(session);
}
Ok(None) => {
bail!(
"Codex auth.json no longer contains ChatGPT tokens. \
Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
);
}
Err(err) => {
last_err = Some(err);
if attempt + 1 < CODEX_REFRESH_MAX_ATTEMPTS {
let delay = CODEX_REFRESH_BACKOFF_MS.get(attempt).copied().unwrap_or(100);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("failed to read codex auth.json after retries")))
}
}
pub fn codex_auth_json_refresher() -> Arc<dyn OpenAIChatGptSessionRefresher> {
Arc::new(CodexAuthJsonRefresher)
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn parse_codex_auth_json_with_tokens() {
let json = r#"{
"OPENAI_API_KEY": "sk-derived-key",
"tokens": {
"id_token": "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh",
"account_id": "acc-123"
},
"last_refresh": "2025-01-15T12:00:00Z"
}"#;
let auth: CodexAuthDotJson = serde_json::from_str(json).expect("parse");
assert_eq!(auth.openai_api_key.as_deref(), Some("sk-derived-key"));
let tokens = auth.tokens.expect("tokens present");
assert_eq!(tokens.access_token, "oauth-access");
assert_eq!(tokens.refresh_token, "oauth-refresh");
assert_eq!(tokens.account_id.as_deref(), Some("acc-123"));
}
#[test]
fn parse_codex_auth_json_without_tokens() {
let json = r#"{"OPENAI_API_KEY": "sk-key"}"#;
let auth: CodexAuthDotJson = serde_json::from_str(json).expect("parse");
assert!(auth.tokens.is_none());
}
#[test]
fn codex_tokens_to_session_maps_fields() {
let auth = CodexAuthDotJson {
auth_mode: Some("chatgpt".to_string()),
openai_api_key: Some("sk-derived".to_string()),
tokens: Some(CodexTokenData {
id_token: "header.eyJlbWFpbCI6InVzZXJAdGVzdC5jb20ifQ.sig".to_string(),
access_token: "access-tok".to_string(),
refresh_token: "refresh-tok".to_string(),
account_id: Some("acc-456".to_string()),
}),
last_refresh: None,
personal_access_token: None,
bedrock_api_key: None,
};
let tokens = auth.tokens.clone().unwrap();
let session = codex_tokens_to_session(&auth, &tokens);
assert_eq!(session.openai_api_key, "sk-derived");
assert_eq!(session.access_token, "access-tok");
assert_eq!(session.refresh_token, "");
assert_eq!(session.account_id.as_deref(), Some("acc-456"));
assert_eq!(session.email.as_deref(), Some("user@test.com"));
}
#[test]
fn codex_tokens_to_session_falls_back_to_empty_api_key() {
let auth = CodexAuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: Some(CodexTokenData {
id_token: "header.e30.sig".to_string(),
access_token: "access-tok".to_string(),
refresh_token: "refresh-tok".to_string(),
account_id: None,
}),
last_refresh: None,
personal_access_token: None,
bedrock_api_key: None,
};
let tokens = auth.tokens.clone().unwrap();
let session = codex_tokens_to_session(&auth, &tokens);
assert!(session.openai_api_key.is_empty());
assert!(session.account_id.is_none());
}
#[tokio::test]
#[serial]
async fn codex_refresher_fails_when_no_auth_file() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let refresher = CodexAuthJsonRefresher;
let session = OpenAIChatGptSession {
openai_api_key: String::new(),
id_token: String::new(),
access_token: String::new(),
refresh_token: String::new(),
account_id: None,
email: None,
plan: None,
obtained_at: 0,
refreshed_at: 0,
expires_at: None,
};
let result = refresher.refresh_session(&session).await;
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no longer contains ChatGPT tokens"));
}
#[test]
#[serial]
fn codex_apikey_mode_is_rejected_as_chatgpt_fallback() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"auth_mode": "apikey",
"OPENAI_API_KEY": "sk-test",
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(session.is_ok(), "parsing should not error");
assert!(session.unwrap().is_none(), "apikey mode should not produce a ChatGPT session");
}
#[test]
#[serial]
fn codex_blank_access_token_is_rejected() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"auth_mode": "chatgpt",
"tokens": {
"id_token": "header.e30.sig",
"access_token": " ",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(session.is_ok());
assert!(session.unwrap().is_none(), "blank access_token should not produce a session");
}
#[test]
#[serial]
fn codex_chatgpt_mode_with_access_token_is_accepted() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"auth_mode": "chatgpt",
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access-token",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let session = session.expect("parse ok").expect("session should be present");
assert_eq!(session.access_token, "oauth-access-token");
assert!(session.openai_api_key.is_empty(), "no derived API key in this fixture");
}
#[test]
#[serial]
fn codex_no_auth_mode_with_api_key_is_rejected() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"OPENAI_API_KEY": "",
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(session.is_ok());
assert!(
session.unwrap().is_none(),
"missing auth_mode + present (even empty) API key should be API-key mode, not ChatGPT"
);
}
#[test]
#[serial]
fn codex_no_auth_mode_no_api_key_defaults_to_chatgpt() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(session.is_ok());
assert!(session.unwrap().is_some(), "missing auth_mode + no API key + tokens should default to ChatGPT");
}
#[test]
#[serial]
fn codex_pat_presence_suppresses_chatgpt_fallback() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"personal_access_token": "pat-secret-value",
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(session.is_ok());
assert!(session.unwrap().is_none(), "PAT presence (even with tokens) should suppress ChatGPT fallback");
}
#[test]
#[serial]
fn codex_bedrock_presence_suppresses_chatgpt_fallback() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"bedrock_api_key": {"api_key": "bedrock-secret", "region": "us-east-1"},
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(session.is_ok());
assert!(session.unwrap().is_none(), "Bedrock presence (even with tokens) should suppress ChatGPT fallback");
}
#[test]
#[serial]
fn codex_explicit_chatgpt_mode_overrides_pat_presence() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let auth_json = r#"{
"auth_mode": "chatgpt",
"personal_access_token": "pat-should-be-ignored",
"tokens": {
"id_token": "header.e30.sig",
"access_token": "oauth-access",
"refresh_token": "oauth-refresh"
}
}"#;
std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let session = session.expect("parse ok").expect("session should be present");
assert_eq!(session.access_token, "oauth-access");
}
#[test]
fn codex_auth_json_debug_never_leaks_token_values() {
let auth = CodexAuthDotJson {
auth_mode: Some("chatgpt".to_string()),
openai_api_key: Some("sk-secret-key".to_string()),
tokens: Some(CodexTokenData {
id_token: "id-secret".to_string(),
access_token: "access-secret".to_string(),
refresh_token: "refresh-secret".to_string(),
account_id: Some("acc-123".to_string()),
}),
last_refresh: None,
personal_access_token: Some(RedactedPresence { _present: true }),
bedrock_api_key: Some(RedactedPresence { _present: true }),
};
let debug_str = format!("{auth:?}");
assert!(!debug_str.contains("sk-secret-key"), "api key leaked: {debug_str}");
assert!(!debug_str.contains("id-secret"), "id_token leaked: {debug_str}");
assert!(!debug_str.contains("access-secret"), "access_token leaked: {debug_str}");
assert!(!debug_str.contains("refresh-secret"), "refresh_token leaked: {debug_str}");
assert!(debug_str.contains("chatgpt"), "auth_mode should be visible: {debug_str}");
assert!(!debug_str.contains("acc-123"), "account_id inside tokens should be redacted: {debug_str}");
}
fn make_jwt_with_exp(exp: u64) -> String {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let payload = format!(r#"{{"exp":{exp}}}"#);
let encoded = URL_SAFE_NO_PAD.encode(payload.as_bytes());
format!("header.{encoded}.sig")
}
fn make_jwt_without_exp() -> String {
"opaque-access-token-not-a-jwt".to_string()
}
fn write_codex_auth_json(dir: &std::path::Path, json: &str) {
std::fs::write(dir.join("auth.json"), json).expect("write auth.json");
}
fn codex_auth_json_with_access_token(access_token: &str) -> String {
format!(
r#"{{"auth_mode":"chatgpt","tokens":{{"id_token":"header.e30.sig","access_token":"{access_token}","refresh_token":"oauth-refresh"}}}}"#
)
}
#[test]
#[serial]
fn initial_load_returns_ok_none_when_file_absent() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let result = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(result.is_ok(), "missing file should be Ok(None), not Err");
assert!(result.unwrap().is_none());
}
#[test]
#[serial]
fn initial_load_retries_partial_write_then_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
write_codex_auth_json(temp.path(), r#"{"auth_mode":"chatg"#);
let path = temp.path().join("auth.json");
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(15));
std::fs::write(&path, codex_auth_json_with_access_token("oauth-access")).expect("complete the file");
});
let result = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let session = result.expect("should succeed after retry");
assert!(session.is_some(), "retry should absorb the partial write and return the session");
assert_eq!(session.unwrap().access_token, "oauth-access");
}
#[test]
#[serial]
fn initial_load_returns_path_neutral_error_after_retries_exhausted() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
write_codex_auth_json(temp.path(), "this is not json at all {{{{");
let result = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let err = result.expect_err("permanently invalid JSON should error");
let msg = err.to_string();
assert!(msg.contains("failed to parse codex auth.json"), "error should mention parse failure: {msg}");
assert!(!msg.contains(temp.path().to_str().unwrap()), "error must not leak absolute path: {msg}");
}
#[tokio::test]
#[serial]
async fn refresher_rejects_changed_but_expired_access_token() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
let current = OpenAIChatGptSession {
openai_api_key: String::new(),
id_token: String::new(),
access_token: expired_jwt.clone(),
refresh_token: String::new(),
account_id: None,
email: None,
plan: None,
obtained_at: 0,
refreshed_at: 0,
expires_at: Some(now_secs().saturating_sub(3600)),
};
let new_expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(1800));
write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&new_expired_jwt));
let refresher = CodexAuthJsonRefresher;
let result = refresher.refresh_session(¤t).await;
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let err = result.expect_err("expired replacement should be rejected");
assert!(
err.to_string().contains("expired replacement access token"),
"should mention expired replacement: {err}"
);
}
#[tokio::test]
#[serial]
async fn refresher_accepts_changed_valid_access_token() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
let current = OpenAIChatGptSession {
openai_api_key: String::new(),
id_token: String::new(),
access_token: expired_jwt,
refresh_token: String::new(),
account_id: None,
email: None,
plan: None,
obtained_at: 0,
refreshed_at: 0,
expires_at: Some(now_secs().saturating_sub(3600)),
};
let valid_jwt = make_jwt_with_exp(now_secs().saturating_add(3600));
write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&valid_jwt));
let refresher = CodexAuthJsonRefresher;
let result = refresher.refresh_session(¤t).await;
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let session = result.expect("valid replacement should be accepted");
assert!(!session.access_token.is_empty(), "should return the refreshed session");
}
#[tokio::test]
#[serial]
async fn refresher_rejects_unchanged_expired_access_token() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
let current = OpenAIChatGptSession {
openai_api_key: String::new(),
id_token: String::new(),
access_token: expired_jwt.clone(),
refresh_token: String::new(),
account_id: None,
email: None,
plan: None,
obtained_at: 0,
refreshed_at: 0,
expires_at: Some(now_secs().saturating_sub(3600)),
};
write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&expired_jwt));
let refresher = CodexAuthJsonRefresher;
let result = refresher.refresh_session(¤t).await;
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let err = result.expect_err("unchanged expired token should be rejected");
assert!(err.to_string().contains("has not been refreshed"), "should mention Codex hasn't refreshed: {err}");
}
#[test]
#[serial]
fn unknown_expiry_session_is_treated_as_valid() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let opaque_token = make_jwt_without_exp();
write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&opaque_token));
let session = try_load_codex_chatgpt_session();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let session = session.expect("parse ok").expect("session present");
assert!(session.expires_at.is_none(), "opaque token should have no expiry");
assert!(!is_session_expired(&session), "unknown expiry should be treated as valid (not expired)");
}
#[tokio::test]
#[serial]
async fn refresher_accepts_unknown_expiry_token() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
let opaque_token = make_jwt_without_exp();
write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&opaque_token));
let current = OpenAIChatGptSession {
openai_api_key: String::new(),
id_token: String::new(),
access_token: "previous-opaque".to_string(),
refresh_token: String::new(),
account_id: None,
email: None,
plan: None,
obtained_at: 0,
refreshed_at: 0,
expires_at: None,
};
let refresher = CodexAuthJsonRefresher;
let result = refresher.refresh_session(¤t).await;
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
let session = result.expect("unknown-expiry token should be accepted");
assert_eq!(session.access_token, opaque_token);
}
#[tokio::test]
#[serial]
async fn refresher_uses_single_attempt_primitive() {
let temp = tempfile::tempdir().expect("create temp dir");
let prev = std::env::var("CODEX_HOME").ok();
vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
write_codex_auth_json(temp.path(), "invalid json {{{{");
let current = OpenAIChatGptSession {
openai_api_key: String::new(),
id_token: String::new(),
access_token: "old".to_string(),
refresh_token: String::new(),
account_id: None,
email: None,
plan: None,
obtained_at: 0,
refreshed_at: 0,
expires_at: None,
};
let refresher = CodexAuthJsonRefresher;
let start = std::time::Instant::now();
let result = refresher.refresh_session(¤t).await;
let elapsed = start.elapsed();
vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
assert!(result.is_err(), "invalid file should error");
assert!(
elapsed < std::time::Duration::from_millis(200),
"refresher should not multiply retry delays (took {elapsed:?})"
);
}
}