claude_codex/providers/codex/auth/
token_store.rs1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use super::jwt::{TokenResponse, extract_account_id, token_exp_ms};
7use crate::auth::AuthStorage;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct StoredAuth {
11 pub access: String,
12 pub refresh: String,
13 pub expires: u64,
14 #[serde(
15 default,
16 rename = "accountId",
17 alias = "account_id",
18 skip_serializing_if = "Option::is_none"
19 )]
20 pub account_id: Option<String>,
21}
22
23pub struct CodexTokenStore<S: AuthStorage<StoredAuth>> {
24 store: S,
25}
26
27impl<S: AuthStorage<StoredAuth>> CodexTokenStore<S> {
28 pub fn new(store: S) -> Self {
29 Self { store }
30 }
31
32 pub fn load_auth(&self) -> Result<Option<StoredAuth>, anyhow::Error> {
33 self.store.load()
34 }
35
36 pub fn save_auth(&self, value: StoredAuth) -> Result<(), anyhow::Error> {
37 self.store.save(value)
38 }
39
40 pub fn clear_auth(&self) -> Result<(), anyhow::Error> {
41 self.store.clear()
42 }
43
44 pub fn auth_path(&self) -> String {
45 self.store.path()
46 }
47}
48
49pub struct CodexCliAuthStore {
56 path: PathBuf,
57}
58
59impl CodexCliAuthStore {
60 pub fn new(path: PathBuf) -> Self {
61 Self { path }
62 }
63}
64
65impl AuthStorage<StoredAuth> for CodexCliAuthStore {
66 fn load(&self) -> Result<Option<StoredAuth>, anyhow::Error> {
67 let raw = match std::fs::read(&self.path) {
68 Ok(bytes) => bytes,
69 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
70 Err(err) => {
71 return Err(anyhow::anyhow!(
72 "failed to read {}: {err}",
73 self.path.display()
74 ));
75 }
76 };
77 let doc: serde_json::Value = serde_json::from_slice(&raw)
78 .map_err(|e| anyhow::anyhow!("{} is not valid JSON: {e}", self.path.display()))?;
79 let Some(tokens) = doc.get("tokens") else {
80 return Ok(None);
81 };
82 let access = tokens
83 .get("access_token")
84 .and_then(|v| v.as_str())
85 .unwrap_or_default()
86 .to_string();
87 if access.is_empty() {
88 return Ok(None);
89 }
90 let refresh = tokens
91 .get("refresh_token")
92 .and_then(|v| v.as_str())
93 .unwrap_or_default()
94 .to_string();
95 let account_id = tokens
96 .get("account_id")
97 .and_then(|v| v.as_str())
98 .map(str::to_string)
99 .or_else(|| {
100 let probe = TokenResponse {
101 id_token: tokens
102 .get("id_token")
103 .and_then(|v| v.as_str())
104 .map(str::to_string),
105 access_token: access.clone(),
106 refresh_token: refresh.clone(),
107 expires_in: None,
108 };
109 extract_account_id(&probe)
110 });
111 let expires = token_exp_ms(&access).unwrap_or_else(|| now_ms() + 3_600_000);
115 Ok(Some(StoredAuth {
116 access,
117 refresh,
118 expires,
119 account_id,
120 }))
121 }
122
123 fn save(&self, value: StoredAuth) -> Result<(), anyhow::Error> {
124 let mut doc: serde_json::Value = match std::fs::read(&self.path) {
126 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({})),
127 Err(_) => serde_json::json!({}),
128 };
129 if !doc.is_object() {
130 doc = serde_json::json!({});
131 }
132 let obj = doc.as_object_mut().expect("doc is a json object");
133 let tokens = obj.entry("tokens").or_insert_with(|| serde_json::json!({}));
134 if !tokens.is_object() {
135 *tokens = serde_json::json!({});
136 }
137 let tokens = tokens.as_object_mut().expect("tokens is a json object");
138 tokens.insert(
139 "access_token".into(),
140 serde_json::Value::String(value.access),
141 );
142 tokens.insert(
143 "refresh_token".into(),
144 serde_json::Value::String(value.refresh),
145 );
146 if let Some(account_id) = value.account_id {
147 tokens.insert("account_id".into(), serde_json::Value::String(account_id));
148 }
149 obj.insert(
150 "last_refresh".into(),
151 serde_json::Value::String(now_rfc3339()),
152 );
153 write_atomic(&self.path, &serde_json::to_vec_pretty(&doc)?)
154 }
155
156 fn clear(&self) -> Result<(), anyhow::Error> {
157 Ok(())
160 }
161
162 fn path(&self) -> String {
163 self.path.to_string_lossy().into_owned()
164 }
165}
166
167pub type DefaultCodexAuthStore = CodexCliAuthStore;
168
169pub fn codex_auth_file() -> PathBuf {
171 if let Some(explicit) = std::env::var_os("CCP_CODEX_AUTH_FILE") {
172 return PathBuf::from(explicit);
173 }
174 if let Some(home) = std::env::var_os("CODEX_HOME") {
175 return PathBuf::from(home).join("auth.json");
176 }
177 if let Some(home) = std::env::var_os("HOME") {
178 return PathBuf::from(home).join(".codex").join("auth.json");
179 }
180 PathBuf::from(".codex").join("auth.json")
181}
182
183pub fn file_store() -> CodexTokenStore<DefaultCodexAuthStore> {
184 CodexTokenStore::new(CodexCliAuthStore::new(codex_auth_file()))
185}
186
187fn now_ms() -> u64 {
188 std::time::SystemTime::now()
189 .duration_since(std::time::UNIX_EPOCH)
190 .unwrap_or_default()
191 .as_millis() as u64
192}
193
194fn now_rfc3339() -> String {
195 time::OffsetDateTime::now_utc()
196 .format(&time::format_description::well_known::Rfc3339)
197 .unwrap_or_default()
198}
199
200fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), anyhow::Error> {
201 let dir = path
202 .parent()
203 .ok_or_else(|| anyhow::anyhow!("auth path has no parent directory"))?;
204 std::fs::create_dir_all(dir).ok();
205 let tmp = dir.join(format!(".auth.json.tmp.{}", std::process::id()));
206 {
207 let mut file = std::fs::File::create(&tmp)?;
208 #[cfg(unix)]
209 {
210 use std::os::unix::fs::PermissionsExt;
211 let mut perm = file.metadata()?.permissions();
212 perm.set_mode(0o600);
213 file.set_permissions(perm)?;
214 }
215 file.write_all(bytes)?;
216 file.flush()?;
217 }
218 std::fs::rename(&tmp, path).map_err(|e| {
219 let _ = std::fs::remove_file(&tmp);
220 anyhow::anyhow!("failed to persist {}: {e}", path.display())
221 })
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::auth::InMemoryAuthStore;
228 use serde_json::json;
229
230 #[test]
231 fn stored_auth_reads_account_id_alias() {
232 let auth: StoredAuth = serde_json::from_value(json!({
233 "access": "a",
234 "refresh": "r",
235 "expires": 123,
236 "accountId": "acct"
237 }))
238 .unwrap();
239 assert_eq!(auth.account_id.as_deref(), Some("acct"));
240 }
241
242 #[test]
243 fn stored_auth_roundtrip() {
244 let store = CodexTokenStore::new(InMemoryAuthStore::new());
245 let auth = StoredAuth {
246 access: "token".into(),
247 refresh: "refresh".into(),
248 expires: 9999999999999,
249 account_id: Some("acct_1".into()),
250 };
251 store.save_auth(auth.clone()).unwrap();
252 let loaded = store.load_auth().unwrap().unwrap();
253 assert_eq!(loaded.access, "token");
254 assert_eq!(loaded.account_id.as_deref(), Some("acct_1"));
255 }
256
257 fn write_auth_json(dir: &std::path::Path, access: &str) -> PathBuf {
258 let path = dir.join("auth.json");
259 let doc = json!({
260 "auth_mode": "chatgpt",
261 "OPENAI_API_KEY": null,
262 "tokens": {
263 "id_token": "id.tok.sig",
264 "access_token": access,
265 "refresh_token": "refresh-abc",
266 "account_id": "acct_file"
267 },
268 "last_refresh": "2026-07-13T08:00:00Z"
269 });
270 std::fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
271 path
272 }
273
274 const ACCESS_JWT_2100: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.sig";
276
277 #[test]
278 fn load_missing_file_returns_none() {
279 let dir = tempfile::TempDir::new().unwrap();
280 let store = CodexCliAuthStore::new(dir.path().join("auth.json"));
281 assert!(store.load().unwrap().is_none());
282 }
283
284 #[test]
285 fn load_maps_tokens_and_account_id_from_file() {
286 let dir = tempfile::TempDir::new().unwrap();
287 let path = write_auth_json(dir.path(), ACCESS_JWT_2100);
288 let store = CodexCliAuthStore::new(path);
289 let auth = store.load().unwrap().unwrap();
290 assert_eq!(auth.access, ACCESS_JWT_2100);
291 assert_eq!(auth.refresh, "refresh-abc");
292 assert_eq!(auth.account_id.as_deref(), Some("acct_file"));
293 assert_eq!(auth.expires, 4102444800 * 1000);
294 }
295
296 #[test]
297 fn save_preserves_unowned_fields_and_updates_tokens() {
298 let dir = tempfile::TempDir::new().unwrap();
299 let path = write_auth_json(dir.path(), ACCESS_JWT_2100);
300 let store = CodexCliAuthStore::new(path.clone());
301 store
302 .save(StoredAuth {
303 access: "new-access".into(),
304 refresh: "new-refresh".into(),
305 expires: 0,
306 account_id: Some("acct_new".into()),
307 })
308 .unwrap();
309 let doc: serde_json::Value =
310 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
311 assert_eq!(doc["tokens"]["access_token"], "new-access");
312 assert_eq!(doc["tokens"]["refresh_token"], "new-refresh");
313 assert_eq!(doc["tokens"]["account_id"], "acct_new");
314 assert_eq!(doc["auth_mode"], "chatgpt");
316 assert!(doc.as_object().unwrap().contains_key("OPENAI_API_KEY"));
317 assert_eq!(doc["tokens"]["id_token"], "id.tok.sig");
318 assert_ne!(doc["last_refresh"], "2026-07-13T08:00:00Z");
319 }
320
321 #[test]
322 fn save_sets_0600_permissions() {
323 let dir = tempfile::TempDir::new().unwrap();
324 let path = write_auth_json(dir.path(), ACCESS_JWT_2100);
325 let store = CodexCliAuthStore::new(path.clone());
326 store
327 .save(StoredAuth {
328 access: "a".into(),
329 refresh: "r".into(),
330 expires: 0,
331 account_id: None,
332 })
333 .unwrap();
334 #[cfg(unix)]
335 {
336 use std::os::unix::fs::PermissionsExt;
337 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
338 assert_eq!(mode, 0o600);
339 }
340 }
341
342 #[test]
343 fn clear_is_noop_and_keeps_file() {
344 let dir = tempfile::TempDir::new().unwrap();
345 let path = write_auth_json(dir.path(), ACCESS_JWT_2100);
346 let store = CodexCliAuthStore::new(path.clone());
347 store.clear().unwrap();
348 assert!(path.exists(), "clear must not delete the Codex CLI file");
349 }
350}