ai_usagebar/openai/
creds.rs1use std::path::{Path, PathBuf};
5
6use base64::Engine;
7use serde::{Deserialize, Serialize};
8
9use crate::cache::atomic_write;
10use crate::error::{AppError, Result};
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct AuthFile {
14 pub tokens: Tokens,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub last_refresh: Option<String>,
17 #[serde(flatten, default)]
18 pub extra: serde_json::Map<String, serde_json::Value>,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct Tokens {
23 pub access_token: String,
24 pub refresh_token: String,
25 pub id_token: String,
26 #[serde(default)]
27 pub account_id: Option<String>,
28 #[serde(default)]
31 pub expires_at: Option<String>,
32 #[serde(flatten, default)]
33 pub extra: serde_json::Map<String, serde_json::Value>,
34}
35
36pub fn default_path() -> Result<PathBuf> {
42 Ok(crate::cache::home_dir()?.join(".codex").join("auth.json"))
43}
44
45pub fn read_from(path: &Path) -> Result<AuthFile> {
46 let raw = std::fs::read_to_string(path).map_err(|e| AppError::io_at(path, e))?;
47 serde_json::from_str(&raw).map_err(|e| {
48 AppError::Credentials(format!(
49 "could not parse {}: {e}. Run `codex login` to re-authenticate.",
50 path.display()
51 ))
52 })
53}
54
55pub fn write_back(path: &Path, auth: &AuthFile) -> Result<()> {
57 let bytes = serde_json::to_vec_pretty(auth).map_err(AppError::Json)?;
58 atomic_write(path, &bytes)
59}
60
61impl Tokens {
62 pub fn expires_at_secs(&self) -> i64 {
70 self.expires_at
71 .as_deref()
72 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
73 .map(|dt| dt.timestamp())
74 .or_else(|| parse_jwt_exp(&self.access_token))
75 .or_else(|| parse_jwt_exp(&self.id_token))
76 .unwrap_or(0)
77 }
78
79 pub fn plan_type_from_id_token(&self) -> Option<String> {
82 let claims = parse_jwt_claims(&self.id_token)?;
83 claims
84 .get("https://api.openai.com/auth")
85 .and_then(|v| v.get("chatgpt_plan_type"))
86 .and_then(|v| v.as_str())
87 .map(|s| s.to_string())
88 }
89}
90
91fn parse_jwt_exp(token: &str) -> Option<i64> {
93 let claims = parse_jwt_claims(token)?;
94 claims
95 .get("exp")
96 .and_then(|v| v.as_i64())
97 .or_else(|| claims.get("exp").and_then(|v| v.as_f64()).map(|f| f as i64))
98}
99
100fn parse_jwt_claims(token: &str) -> Option<serde_json::Value> {
101 let mut parts = token.split('.');
102 let _header = parts.next()?;
103 let payload = parts.next()?;
104 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
105 .decode(payload)
106 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
107 .ok()?;
108 serde_json::from_slice(&decoded).ok()
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::io::Write;
115 use tempfile::{NamedTempFile, TempDir};
116
117 fn write_auth(s: &str) -> NamedTempFile {
118 let mut f = NamedTempFile::new().unwrap();
119 f.write_all(s.as_bytes()).unwrap();
120 f.flush().unwrap();
121 f
122 }
123
124 fn write_auth_closed(s: &str) -> (TempDir, std::path::PathBuf) {
128 crate::cache::closed_temp_file("auth.json", Some(s))
129 }
130
131 fn fake_jwt(claims: serde_json::Value) -> String {
133 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
134 .encode(br#"{"alg":"none","typ":"JWT"}"#);
135 let payload =
136 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
137 format!("{header}.{payload}.sig")
138 }
139
140 #[test]
141 fn parses_minimal_auth_file() {
142 let jwt = fake_jwt(serde_json::json!({"exp": 1234567890}));
143 let body = format!(
144 r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT",
145 "id_token":"{jwt}","account_id":"acc"}}}}"#
146 );
147 let f = write_auth(&body);
148 let auth = read_from(f.path()).unwrap();
149 assert_eq!(auth.tokens.access_token, "AT");
150 assert_eq!(auth.tokens.account_id.as_deref(), Some("acc"));
151 assert_eq!(auth.tokens.expires_at_secs(), 1234567890);
152 }
153
154 #[test]
155 fn extracts_plan_type_from_id_token() {
156 let jwt = fake_jwt(serde_json::json!({
157 "exp": 1234567890,
158 "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
159 }));
160 let body = format!(
161 r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}"}}}}"#
162 );
163 let f = write_auth(&body);
164 let auth = read_from(f.path()).unwrap();
165 assert_eq!(
166 auth.tokens.plan_type_from_id_token().as_deref(),
167 Some("plus")
168 );
169 }
170
171 #[test]
172 fn malformed_jwt_returns_zero_exp() {
173 let body = r#"{"tokens":{"access_token":"x","refresh_token":"y","id_token":"not.a.jwt"}}"#;
174 let f = write_auth(body);
175 let auth = read_from(f.path()).unwrap();
176 assert_eq!(auth.tokens.expires_at_secs(), 0);
177 assert!(auth.tokens.plan_type_from_id_token().is_none());
178 }
179
180 #[test]
181 fn explicit_expires_at_overrides_an_old_expired_id_token() {
182 let expired_jwt = fake_jwt(serde_json::json!({"exp": 1}));
186 let mut tokens = Tokens {
187 access_token: "AT".into(),
188 refresh_token: "RT".into(),
189 id_token: expired_jwt,
190 account_id: None,
191 expires_at: Some("2030-01-01T00:00:00Z".into()),
192 extra: Default::default(),
193 };
194 let expected = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
195 .unwrap()
196 .timestamp();
197 assert_eq!(tokens.expires_at_secs(), expected);
198
199 tokens.expires_at = Some("whenever".into());
201 assert_eq!(tokens.expires_at_secs(), 1);
202 }
203
204 #[test]
205 fn access_token_expiry_wins_over_the_legacy_id_token_claim() {
206 let tokens = Tokens {
207 access_token: fake_jwt(serde_json::json!({"exp": 2_000_000_000})),
208 refresh_token: "RT".into(),
209 id_token: fake_jwt(serde_json::json!({"exp": 1})),
210 account_id: None,
211 expires_at: None,
212 extra: Default::default(),
213 };
214 assert_eq!(tokens.expires_at_secs(), 2_000_000_000);
215 }
216
217 #[test]
218 fn malformed_file_returns_credentials_error() {
219 let f = write_auth("not json");
220 let err = read_from(f.path()).unwrap_err();
221 assert!(matches!(err, AppError::Credentials(_)));
222 }
223
224 #[test]
225 fn write_back_preserves_unknown_fields() {
226 let jwt = fake_jwt(serde_json::json!({"exp": 1234567890}));
227 let body = format!(
228 r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}"}},
229 "some_other_field":"keep-me"}}"#
230 );
231 let (_dir, path) = write_auth_closed(&body);
232 let mut auth = read_from(&path).unwrap();
233 auth.tokens.access_token = "NEW".into();
234 write_back(&path, &auth).unwrap();
235
236 let v: serde_json::Value =
237 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
238 assert_eq!(v["some_other_field"], "keep-me");
239 assert_eq!(v["tokens"]["access_token"], "NEW");
240 }
241
242 #[test]
243 fn default_path_ends_with_codex_auth() {
244 let p = default_path().unwrap();
245 assert!(p.ends_with(std::path::Path::new(".codex").join("auth.json")));
248 }
249
250 #[cfg(windows)]
252 #[test]
253 fn default_path_uses_userprofile_on_windows() {
254 let p = default_path().unwrap();
255 let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
256 let norm = |s: &str| s.to_lowercase().replace('/', "\\");
261 let p_norm = norm(&p.to_string_lossy());
262 let up_norm = norm(&userprofile);
263 assert!(
264 p_norm.starts_with(up_norm.as_str()),
265 "{} should live under {}",
266 p.display(),
267 userprofile
268 );
269 }
270}