use std::path::{Path, PathBuf};
use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::cache::atomic_write;
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthFile {
pub tokens: Tokens,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_refresh: Option<String>,
#[serde(flatten, default)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Tokens {
pub access_token: String,
pub refresh_token: String,
pub id_token: String,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(flatten, default)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
pub fn default_path() -> Result<PathBuf> {
Ok(crate::cache::home_dir()?.join(".codex").join("auth.json"))
}
pub fn read_from(path: &Path) -> Result<AuthFile> {
let raw = std::fs::read_to_string(path).map_err(|e| AppError::io_at(path, e))?;
serde_json::from_str(&raw).map_err(|e| {
AppError::Credentials(format!(
"could not parse {}: {e}. Run `codex login` to re-authenticate.",
path.display()
))
})
}
pub fn write_back(path: &Path, auth: &AuthFile) -> Result<()> {
let bytes = serde_json::to_vec_pretty(auth).map_err(AppError::Json)?;
atomic_write(path, &bytes)
}
impl Tokens {
pub fn expires_at_secs(&self) -> i64 {
parse_jwt_exp(&self.id_token).unwrap_or(0)
}
pub fn plan_type_from_id_token(&self) -> Option<String> {
let claims = parse_jwt_claims(&self.id_token)?;
claims
.get("https://api.openai.com/auth")
.and_then(|v| v.get("chatgpt_plan_type"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
}
fn parse_jwt_exp(token: &str) -> Option<i64> {
let claims = parse_jwt_claims(token)?;
claims
.get("exp")
.and_then(|v| v.as_i64())
.or_else(|| claims.get("exp").and_then(|v| v.as_f64()).map(|f| f as i64))
}
fn parse_jwt_claims(token: &str) -> Option<serde_json::Value> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
.ok()?;
serde_json::from_slice(&decoded).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
fn write_auth(s: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(s.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn write_auth_closed(s: &str) -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, s).unwrap();
(dir, path)
}
fn fake_jwt(claims: serde_json::Value) -> String {
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(br#"{"alg":"none","typ":"JWT"}"#);
let payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
format!("{header}.{payload}.sig")
}
#[test]
fn parses_minimal_auth_file() {
let jwt = fake_jwt(serde_json::json!({"exp": 1234567890}));
let body = format!(
r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT",
"id_token":"{jwt}","account_id":"acc"}}}}"#
);
let f = write_auth(&body);
let auth = read_from(f.path()).unwrap();
assert_eq!(auth.tokens.access_token, "AT");
assert_eq!(auth.tokens.account_id.as_deref(), Some("acc"));
assert_eq!(auth.tokens.expires_at_secs(), 1234567890);
}
#[test]
fn extracts_plan_type_from_id_token() {
let jwt = fake_jwt(serde_json::json!({
"exp": 1234567890,
"https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
}));
let body = format!(
r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}"}}}}"#
);
let f = write_auth(&body);
let auth = read_from(f.path()).unwrap();
assert_eq!(
auth.tokens.plan_type_from_id_token().as_deref(),
Some("plus")
);
}
#[test]
fn malformed_jwt_returns_zero_exp() {
let body = r#"{"tokens":{"access_token":"x","refresh_token":"y","id_token":"not.a.jwt"}}"#;
let f = write_auth(body);
let auth = read_from(f.path()).unwrap();
assert_eq!(auth.tokens.expires_at_secs(), 0);
assert!(auth.tokens.plan_type_from_id_token().is_none());
}
#[test]
fn malformed_file_returns_credentials_error() {
let f = write_auth("not json");
let err = read_from(f.path()).unwrap_err();
assert!(matches!(err, AppError::Credentials(_)));
}
#[test]
fn write_back_preserves_unknown_fields() {
let jwt = fake_jwt(serde_json::json!({"exp": 1234567890}));
let body = format!(
r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}"}},
"some_other_field":"keep-me"}}"#
);
let (_dir, path) = write_auth_closed(&body);
let mut auth = read_from(&path).unwrap();
auth.tokens.access_token = "NEW".into();
write_back(&path, &auth).unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["some_other_field"], "keep-me");
assert_eq!(v["tokens"]["access_token"], "NEW");
}
#[test]
fn default_path_ends_with_codex_auth() {
let p = default_path().unwrap();
assert!(p.ends_with(std::path::Path::new(".codex").join("auth.json")));
}
#[cfg(windows)]
#[test]
fn default_path_uses_userprofile_on_windows() {
let p = default_path().unwrap();
let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
let norm = |s: &str| s.to_lowercase().replace('/', "\\");
let p_norm = norm(&p.to_string_lossy());
let up_norm = norm(&userprofile);
assert!(
p_norm.starts_with(up_norm.as_str()),
"{} should live under {}",
p.display(),
userprofile
);
}
}