ai_usagebar/openai/
creds.rs1use 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 #[serde(default)]
30 pub expires_at: Option<String>,
31 #[serde(flatten, default)]
32 pub extra: serde_json::Map<String, serde_json::Value>,
33}
34
35pub 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
54pub 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 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 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
90fn 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 fn write_auth_closed(s: &str) -> (TempDir, std::path::PathBuf) {
117 crate::cache::closed_temp_file("auth.json", Some(s))
118 }
119
120 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 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 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 assert!(p.ends_with(std::path::Path::new(".codex").join("auth.json")));
237 }
238
239 #[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 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}