Skip to main content

ai_usagebar/openai/
creds.rs

1//! Read and write `~/.codex/auth.json` — the OAuth state the OpenAI Codex CLI
2//! maintains. Mirrors codexbar's jq paths.
3
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::cache::atomic_write;
9use crate::error::{AppError, Result};
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct AuthFile {
13    pub tokens: Tokens,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub last_refresh: Option<String>,
16    #[serde(flatten, default)]
17    pub extra: serde_json::Map<String, serde_json::Value>,
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct Tokens {
22    pub access_token: String,
23    pub refresh_token: String,
24    pub id_token: String,
25    #[serde(default)]
26    pub account_id: Option<String>,
27    /// Optional explicit expiry from the OAuth server. When absent, we infer
28    /// from the id_token's `exp` claim.
29    #[serde(default)]
30    pub expires_at: Option<String>,
31    #[serde(flatten, default)]
32    pub extra: serde_json::Map<String, serde_json::Value>,
33}
34
35/// Default location: `~/.codex/auth.json` (Unix/macOS) or
36/// `%USERPROFILE%\.codex\auth.json` (Windows).
37///
38/// Home is resolved through [`crate::cache::home_dir`] so every platform's
39/// convention is honored in one place.
40pub fn default_path() -> Result<PathBuf> {
41    Ok(crate::cache::home_dir()?.join(".codex").join("auth.json"))
42}
43
44pub fn read_from(path: &Path) -> Result<AuthFile> {
45    let raw = std::fs::read_to_string(path).map_err(|e| AppError::io_at(path, e))?;
46    serde_json::from_str(&raw).map_err(|e| {
47        AppError::Credentials(format!(
48            "could not parse {}: {e}. Run `codex login` to re-authenticate.",
49            path.display()
50        ))
51    })
52}
53
54/// Persist updated tokens, preserving any unknown fields. Atomic.
55pub fn write_back(path: &Path, auth: &AuthFile) -> Result<()> {
56    let bytes = serde_json::to_vec_pretty(auth).map_err(AppError::Json)?;
57    atomic_write(path, &bytes)
58}
59
60impl Tokens {
61    /// Compute the Unix-seconds expiry. A persisted `expires_at` is the newest
62    /// information after a refresh and must win over the id_token's claim: the
63    /// token endpoint may return `expires_in` without returning a replacement
64    /// id_token, leaving the old (expired) claim in place. Fall back to the
65    /// access-token JWT (the source current Codex itself uses), then the legacy
66    /// id-token claim, for auth files that do not carry the explicit field.
67    /// Returns 0 (forcing an immediate refresh) when neither is usable.
68    pub fn expires_at_secs(&self) -> i64 {
69        self.expires_at
70            .as_deref()
71            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
72            .map(|dt| dt.timestamp())
73            .or_else(|| parse_jwt_exp(&self.access_token))
74            .or_else(|| parse_jwt_exp(&self.id_token))
75            .unwrap_or(0)
76    }
77
78    /// Plan tier from the id_token's nested claim
79    /// `https://api.openai.com/auth.chatgpt_plan_type`.
80    pub fn plan_type_from_id_token(&self) -> Option<String> {
81        let claims = crate::jwt::claims(&self.id_token)?;
82        claims
83            .get("https://api.openai.com/auth")
84            .and_then(|v| v.get("chatgpt_plan_type"))
85            .and_then(|v| v.as_str())
86            .map(|s| s.to_string())
87    }
88}
89
90/// Parse a JWT's `exp` claim. Returns None for malformed tokens.
91fn parse_jwt_exp(token: &str) -> Option<i64> {
92    let claims = crate::jwt::claims(token)?;
93    claims
94        .get("exp")
95        .and_then(|v| v.as_i64())
96        .or_else(|| claims.get("exp").and_then(|v| v.as_f64()).map(|f| f as i64))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use base64::Engine as _;
103    use std::io::Write;
104    use tempfile::{NamedTempFile, TempDir};
105
106    fn write_auth(s: &str) -> NamedTempFile {
107        let mut f = NamedTempFile::new().unwrap();
108        f.write_all(s.as_bytes()).unwrap();
109        f.flush().unwrap();
110        f
111    }
112
113    /// Like `write_auth`, but with no open handle on the file, so
114    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
115    /// See [`crate::cache::closed_temp_file`].
116    fn write_auth_closed(s: &str) -> (TempDir, std::path::PathBuf) {
117        crate::cache::closed_temp_file("auth.json", Some(s))
118    }
119
120    /// Build a fake JWT with the given claims (no signature verification).
121    fn fake_jwt(claims: serde_json::Value) -> String {
122        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
123            .encode(br#"{"alg":"none","typ":"JWT"}"#);
124        let payload =
125            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
126        format!("{header}.{payload}.sig")
127    }
128
129    #[test]
130    fn parses_minimal_auth_file() {
131        let jwt = fake_jwt(serde_json::json!({"exp": 1234567890}));
132        let body = format!(
133            r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT",
134                "id_token":"{jwt}","account_id":"acc"}}}}"#
135        );
136        let f = write_auth(&body);
137        let auth = read_from(f.path()).unwrap();
138        assert_eq!(auth.tokens.access_token, "AT");
139        assert_eq!(auth.tokens.account_id.as_deref(), Some("acc"));
140        assert_eq!(auth.tokens.expires_at_secs(), 1234567890);
141    }
142
143    #[test]
144    fn extracts_plan_type_from_id_token() {
145        let jwt = fake_jwt(serde_json::json!({
146            "exp": 1234567890,
147            "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
148        }));
149        let body = format!(
150            r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}"}}}}"#
151        );
152        let f = write_auth(&body);
153        let auth = read_from(f.path()).unwrap();
154        assert_eq!(
155            auth.tokens.plan_type_from_id_token().as_deref(),
156            Some("plus")
157        );
158    }
159
160    #[test]
161    fn malformed_jwt_returns_zero_exp() {
162        let body = r#"{"tokens":{"access_token":"x","refresh_token":"y","id_token":"not.a.jwt"}}"#;
163        let f = write_auth(body);
164        let auth = read_from(f.path()).unwrap();
165        assert_eq!(auth.tokens.expires_at_secs(), 0);
166        assert!(auth.tokens.plan_type_from_id_token().is_none());
167    }
168
169    #[test]
170    fn explicit_expires_at_overrides_an_old_expired_id_token() {
171        // A refresh that returns no new id_token used to leave the old expired
172        // claim in place. The refreshed `expires_at` must win or every later
173        // run refreshes again.
174        let expired_jwt = fake_jwt(serde_json::json!({"exp": 1}));
175        let mut tokens = Tokens {
176            access_token: "AT".into(),
177            refresh_token: "RT".into(),
178            id_token: expired_jwt,
179            account_id: None,
180            expires_at: Some("2030-01-01T00:00:00Z".into()),
181            extra: Default::default(),
182        };
183        let expected = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
184            .unwrap()
185            .timestamp();
186        assert_eq!(tokens.expires_at_secs(), expected);
187
188        // An invalid explicit value still falls back to the JWT.
189        tokens.expires_at = Some("whenever".into());
190        assert_eq!(tokens.expires_at_secs(), 1);
191    }
192
193    #[test]
194    fn access_token_expiry_wins_over_the_legacy_id_token_claim() {
195        let tokens = Tokens {
196            access_token: fake_jwt(serde_json::json!({"exp": 2_000_000_000})),
197            refresh_token: "RT".into(),
198            id_token: fake_jwt(serde_json::json!({"exp": 1})),
199            account_id: None,
200            expires_at: None,
201            extra: Default::default(),
202        };
203        assert_eq!(tokens.expires_at_secs(), 2_000_000_000);
204    }
205
206    #[test]
207    fn malformed_file_returns_credentials_error() {
208        let f = write_auth("not json");
209        let err = read_from(f.path()).unwrap_err();
210        assert!(matches!(err, AppError::Credentials(_)));
211    }
212
213    #[test]
214    fn write_back_preserves_unknown_fields() {
215        let jwt = fake_jwt(serde_json::json!({"exp": 1234567890}));
216        let body = format!(
217            r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}"}},
218                "some_other_field":"keep-me"}}"#
219        );
220        let (_dir, path) = write_auth_closed(&body);
221        let mut auth = read_from(&path).unwrap();
222        auth.tokens.access_token = "NEW".into();
223        write_back(&path, &auth).unwrap();
224
225        let v: serde_json::Value =
226            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
227        assert_eq!(v["some_other_field"], "keep-me");
228        assert_eq!(v["tokens"]["access_token"], "NEW");
229    }
230
231    #[test]
232    fn default_path_ends_with_codex_auth() {
233        let p = default_path().unwrap();
234        // Trailing segments are stable across platforms; only the home prefix
235        // differs (resolved by directories::BaseDirs).
236        assert!(p.ends_with(std::path::Path::new(".codex").join("auth.json")));
237    }
238
239    // On Windows the home prefix is %USERPROFILE%, not $HOME.
240    #[cfg(windows)]
241    #[test]
242    fn default_path_uses_userprofile_on_windows() {
243        let p = default_path().unwrap();
244        let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
245        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
246        // can differ from %USERPROFILE% in casing or path separator. Compare on a
247        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
248        // which compares components case-sensitively even on Windows.
249        let norm = |s: &str| s.to_lowercase().replace('/', "\\");
250        let p_norm = norm(&p.to_string_lossy());
251        let up_norm = norm(&userprofile);
252        assert!(
253            p_norm.starts_with(up_norm.as_str()),
254            "{} should live under {}",
255            p.display(),
256            userprofile
257        );
258    }
259}