Skip to main content

claude_codex/providers/codex/auth/
token_store.rs

1use 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
49/// Credential source for codex requests: the Codex CLI's `auth.json` (ChatGPT login).
50///
51/// The proxy never runs its own login. It reads the tokens the Codex CLI already stored
52/// and, when it refreshes an expired access token, writes the rotated tokens back so the
53/// Codex CLI keeps working. Fields the proxy does not own (auth_mode, OPENAI_API_KEY,
54/// tokens.id_token, any future keys) are preserved untouched.
55pub 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        // The access-token JWT carries the real expiry. If it cannot be parsed, assume a
112        // short validity window so a genuinely stale token surfaces as a 401 (handled by
113        // force_refresh) rather than being trusted indefinitely.
114        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        // Re-read so fields the proxy does not own survive the write.
125        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        // No-op: auth.json is owned by the Codex CLI. The proxy must never delete it;
158        // a failed refresh should leave the file for the user to `codex login` again.
159        Ok(())
160    }
161
162    fn path(&self) -> String {
163        self.path.to_string_lossy().into_owned()
164    }
165}
166
167pub type DefaultCodexAuthStore = CodexCliAuthStore;
168
169/// Locate the Codex CLI credential file, honoring `$CODEX_HOME` (and a test override).
170pub 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    // {"alg":"none"}.{"exp":4102444800}  (exp = year 2100)
275    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        // Fields the proxy does not own are preserved.
315        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}